mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
refactor(provider-ops): 重构认证配置为 schema-driven 模式
后端架构类通过 get_credentials_schema() 返回带 x-* 扩展字段的 JSON Schema, 前端根据 schema 动态渲染表单、构建请求、验证和格式化显示。 新增架构只需后端一个文件,前端零改动。 主要变更: - 删除前端手写模板文件(anyrouter.ts, cubence.ts, nekocode.ts, new-api.ts, yescode.ts) - 新增 schema-utils.ts 实现 schema 到表单的转换、请求构建、验证和格式化 - 新增 field-hooks.ts 支持 schema 声明式字段联动钩子 - 后端 base.py 提供 parse_verify_response 默认实现,减少子类重复代码 - 各架构余额查询逻辑复用 balance.py 中的通用函数 - 删除废弃的 one_api.py 架构,新增架构 hidden 属性 - API 返回 credentials_schema 供前端消费
This commit is contained in:
@@ -45,6 +45,7 @@ export interface ArchitectureInfo {
|
||||
architecture_id: string
|
||||
display_name: string
|
||||
description: string
|
||||
credentials_schema: Record<string, any>
|
||||
supported_auth_types: Array<{
|
||||
type: string
|
||||
display_name: string
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Anyrouter 认证模板
|
||||
*
|
||||
* 适用于 Anyrouter 中转站:
|
||||
* - 使用 Cookie 认证(session)
|
||||
* - 自动处理 acw_sc__v2 反爬 Cookie(后端处理)
|
||||
* - quota 单位是 1/500000 美元
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
export const anyrouterTemplate: AuthTemplate = {
|
||||
id: 'anyrouter',
|
||||
name: 'Anyrouter',
|
||||
description: '适用于 Anyrouter 中转站,使用 Cookie 认证',
|
||||
|
||||
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
|
||||
return [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: '站点地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite || 'https://anyrouter.top',
|
||||
required: !providerWebsite,
|
||||
helpText: '通常为 https://anyrouter.top',
|
||||
},
|
||||
{
|
||||
key: 'session_cookie',
|
||||
label: 'Cookie',
|
||||
type: 'password',
|
||||
placeholder: 'session=MTc2ODc4...; acw_sc__v2=...',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
helpText: '从浏览器开发者工具复制完整 Cookie,或仅填写 session 值',
|
||||
},
|
||||
],
|
||||
},
|
||||
PROXY_FIELD_GROUP,
|
||||
]
|
||||
},
|
||||
|
||||
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
|
||||
const baseUrl = formData.base_url || providerWebsite || ''
|
||||
|
||||
return {
|
||||
architecture_id: 'anyrouter',
|
||||
base_url: baseUrl,
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
session_cookie: formData.session_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||
...proxyData,
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
if (!formData.session_cookie?.trim()) {
|
||||
return '请填写 Session Cookie'
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
formatQuota(quota: number): string {
|
||||
// Anyrouter 的 quota 单位是 1/500000 美元
|
||||
const usd = quota / 500000
|
||||
if (usd >= 1) {
|
||||
return `$${usd.toFixed(2)}`
|
||||
}
|
||||
return `$${usd.toFixed(4)}`
|
||||
},
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Cubence 认证模板
|
||||
*
|
||||
* 适用于 Cubence 中转站:
|
||||
* - 使用 Cookie 认证(token JWT)
|
||||
* - 余额单位直接是美元
|
||||
* - 支持窗口限额查询(5小时/每周)
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 格式化窗口限额显示(百分比格式)
|
||||
* 单位是美元(原始值除以 1000000)
|
||||
*/
|
||||
function formatWindowLimit(limit: {
|
||||
limit?: number
|
||||
used?: number
|
||||
remaining?: number
|
||||
resets_at?: number
|
||||
}): { text: string; percent: number } {
|
||||
if (!limit || limit.remaining === undefined || limit.limit === undefined || limit.limit === 0) {
|
||||
return { text: '-', percent: 0 }
|
||||
}
|
||||
|
||||
// 计算剩余百分比
|
||||
const percent = Math.round((limit.remaining / limit.limit) * 100)
|
||||
|
||||
return { text: `${percent}%`, percent }
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化窗口限额详细信息(用于 tooltip)
|
||||
*/
|
||||
function formatWindowLimitDetail(limit: {
|
||||
limit?: number
|
||||
used?: number
|
||||
remaining?: number
|
||||
resets_at?: number
|
||||
}): string {
|
||||
if (!limit || limit.remaining === undefined || limit.limit === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 转换为美元(原始值除以 1000000)
|
||||
const remaining = (limit.remaining / 1000000).toFixed(2)
|
||||
const total = (limit.limit / 1000000).toFixed(2)
|
||||
|
||||
return `$${remaining} / $${total}`
|
||||
}
|
||||
|
||||
export const cubenceTemplate: AuthTemplate = {
|
||||
id: 'cubence',
|
||||
name: 'Cubence',
|
||||
description: '适用于 Cubence 中转站,使用 Cookie 认证',
|
||||
|
||||
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
|
||||
return [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: '站点地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite || 'https://cubence.com',
|
||||
required: !providerWebsite,
|
||||
helpText: '通常为 https://cubence.com',
|
||||
},
|
||||
{
|
||||
key: 'token_cookie',
|
||||
label: 'Token Cookie',
|
||||
type: 'password',
|
||||
placeholder: 'token=eyJhbGciOiJI...',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
helpText: '从浏览器开发者工具复制 Cookie 中的 token 值(JWT 格式)',
|
||||
},
|
||||
],
|
||||
},
|
||||
PROXY_FIELD_GROUP,
|
||||
]
|
||||
},
|
||||
|
||||
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
|
||||
const baseUrl = formData.base_url || providerWebsite || ''
|
||||
|
||||
return {
|
||||
architecture_id: 'cubence',
|
||||
base_url: baseUrl,
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
token_cookie: formData.token_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
token_cookie: config?.connector?.credentials?.token_cookie || '',
|
||||
...proxyData,
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
if (!formData.token_cookie?.trim()) {
|
||||
return '请填写 Token Cookie'
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
formatQuota(quota: number): string {
|
||||
// Cubence 的余额单位直接是美元
|
||||
if (quota >= 1) {
|
||||
return `$${quota.toFixed(2)}`
|
||||
}
|
||||
return `$${quota.toFixed(4)}`
|
||||
},
|
||||
|
||||
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
|
||||
const items: BalanceExtraItem[] = []
|
||||
|
||||
// 5小时窗口限额
|
||||
if (extra.five_hour_limit) {
|
||||
const limit = extra.five_hour_limit
|
||||
const detail = formatWindowLimitDetail(limit)
|
||||
const { text, percent } = formatWindowLimit(limit)
|
||||
items.push({
|
||||
label: '5h',
|
||||
value: text,
|
||||
percent,
|
||||
resetsAt: limit.resets_at,
|
||||
tooltip: detail || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// 每周窗口限额
|
||||
if (extra.weekly_limit) {
|
||||
const limit = extra.weekly_limit
|
||||
const detail = formatWindowLimitDetail(limit)
|
||||
const { text, percent } = formatWindowLimit(limit)
|
||||
items.push({
|
||||
label: '周',
|
||||
value: text,
|
||||
percent,
|
||||
resetsAt: limit.resets_at,
|
||||
tooltip: detail || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
}
|
||||
109
frontend/src/features/providers/auth-templates/field-hooks.ts
Normal file
109
frontend/src/features/providers/auth-templates/field-hooks.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 字段钩子注册表
|
||||
*
|
||||
* 处理 schema 中 x-field-hooks 定义的客户端逻辑,
|
||||
* 如从 Cookie 解析 user_id 等。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 钩子函数签名
|
||||
* @param value 触发字段的值
|
||||
* @returns 目标字段的值,返回 null 表示不填充
|
||||
*/
|
||||
type FieldHookFn = (value: string) => string | null
|
||||
|
||||
/**
|
||||
* 钩子注册表
|
||||
*/
|
||||
const hooks: Record<string, FieldHookFn> = {
|
||||
parse_new_api_user_id: parseNewApiUserId,
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行字段钩子
|
||||
* @param action 钩子 action 名称
|
||||
* @param value 触发字段的值
|
||||
* @returns 目标字段的值,未找到钩子返回 null
|
||||
*/
|
||||
export function executeFieldHook(action: string, value: string): string | null {
|
||||
const fn = hooks[action]
|
||||
if (!fn) return null
|
||||
return fn(value)
|
||||
}
|
||||
|
||||
// ==================== 钩子实现 ====================
|
||||
|
||||
/**
|
||||
* 从 New API session cookie 解析用户 ID
|
||||
*
|
||||
* New API 的 session cookie 格式:
|
||||
* base64(timestamp|gob_base64|signature)
|
||||
*
|
||||
* gob 数据中包含 id 和 username 字段
|
||||
*
|
||||
* 支持两种输入:
|
||||
* 1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
|
||||
* 2. 仅 session 值: "MTc2ODc4..."
|
||||
*/
|
||||
function parseNewApiUserId(cookie: string): string | null {
|
||||
try {
|
||||
// 提取 session 值
|
||||
let sessionValue = cookie.trim()
|
||||
if (sessionValue.includes('session=')) {
|
||||
const match = sessionValue.match(/session=([^;]+)/)
|
||||
if (match) {
|
||||
sessionValue = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// URL-safe base64 解码
|
||||
const padding = 4 - (sessionValue.length % 4)
|
||||
if (padding !== 4) {
|
||||
sessionValue += '='.repeat(padding)
|
||||
}
|
||||
const standardBase64 = sessionValue.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const decoded = atob(standardBase64)
|
||||
|
||||
// 分割: timestamp|gob_base64|signature
|
||||
const parts = decoded.split('|')
|
||||
if (parts.length < 2) return null
|
||||
|
||||
// 解码 gob 数据(第二部分)
|
||||
let gobB64 = parts[1]
|
||||
const gobPadding = 4 - (gobB64.length % 4)
|
||||
if (gobPadding !== 4) {
|
||||
gobB64 += '='.repeat(gobPadding)
|
||||
}
|
||||
const gobStandardB64 = gobB64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const gobData = atob(gobStandardB64)
|
||||
|
||||
// 解析 gob 编码的 id 字段
|
||||
const idIntPattern = '\x02id\x03int'
|
||||
const idIdx = gobData.indexOf(idIntPattern)
|
||||
if (idIdx === -1) return null
|
||||
|
||||
// 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
|
||||
const valueStart = idIdx + 7 + 2
|
||||
if (valueStart >= gobData.length) return null
|
||||
|
||||
const firstByte = gobData.charCodeAt(valueStart)
|
||||
if (firstByte !== 0) return null
|
||||
|
||||
// 下一个字节是长度标记
|
||||
const marker = gobData.charCodeAt(valueStart + 1)
|
||||
if (marker < 0x80) return null
|
||||
|
||||
const length = 256 - marker
|
||||
if (valueStart + 2 + length > gobData.length) return null
|
||||
|
||||
// 读取 length 字节,大端序转整数
|
||||
let val = 0
|
||||
for (let i = 0; i < length; i++) {
|
||||
val = (val << 8) | gobData.charCodeAt(valueStart + 2 + i)
|
||||
}
|
||||
// gob zigzag 解码:正整数用 2*n 编码
|
||||
return (val >> 1).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,74 +1,11 @@
|
||||
/**
|
||||
* 提供商认证模板注册表
|
||||
* 提供商认证模板
|
||||
*
|
||||
* 集中管理所有认证模板。
|
||||
*
|
||||
* ## 添加新模板的步骤
|
||||
*
|
||||
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`)
|
||||
* 2. 实现 `AuthTemplate` 接口
|
||||
* 3. 在本文件中导入并注册到 `templates` 数组
|
||||
*
|
||||
* ## 模板需要实现的内容
|
||||
*
|
||||
* - `id`: 模板唯一标识(对应后端的 architecture_id)
|
||||
* - `name`: 显示名称
|
||||
* - `description`: 描述文本
|
||||
* - `getFields()`: 返回表单字段定义
|
||||
* - `buildRequest()`: 构建后端 API 请求
|
||||
* - `parseConfig()`: 从已有配置解析表单数据
|
||||
* - `validate()`: 验证表单数据
|
||||
* - `formatQuota()`: (可选)格式化 quota 显示
|
||||
* Schema-Driven 模式:后端返回 JSON Schema(含 x-* 扩展字段),
|
||||
* 前端根据 schema 动态渲染表单、构建请求、格式化显示。
|
||||
* 新增架构只需后端一个文件,前端零改动。
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateRegistry } from './types'
|
||||
import { anyrouterTemplate } from './anyrouter'
|
||||
import { cubenceTemplate } from './cubence'
|
||||
import { nekocodeTemplate } from './nekocode'
|
||||
import { newApiTemplate } from './new-api'
|
||||
import { yescodeTemplate } from './yescode'
|
||||
|
||||
// ==================== 模板注册 ====================
|
||||
// 在这里添加新模板
|
||||
|
||||
const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, nekocodeTemplate, yescodeTemplate]
|
||||
|
||||
// ==================== 注册表实现 ====================
|
||||
|
||||
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 { anyrouterTemplate } from './anyrouter'
|
||||
export { cubenceTemplate } from './cubence'
|
||||
export { nekocodeTemplate } from './nekocode'
|
||||
export { newApiTemplate } from './new-api'
|
||||
export { yescodeTemplate } from './yescode'
|
||||
export * from './schema-utils'
|
||||
export { executeFieldHook } from './field-hooks'
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* NekoCode 认证模板
|
||||
*
|
||||
* 适用于 NekoCode 中转站:
|
||||
* - 使用 Cookie 认证(session)
|
||||
* - 显示余额、每日配额、订阅状态
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
export const nekocodeTemplate: AuthTemplate = {
|
||||
id: 'nekocode',
|
||||
name: 'NekoCode',
|
||||
description: '适用于 NekoCode 中转站,使用 Cookie 认证',
|
||||
|
||||
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
|
||||
return [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: '站点地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite || 'https://nekocode.ai',
|
||||
required: !providerWebsite,
|
||||
helpText: '通常为 https://nekocode.ai',
|
||||
},
|
||||
{
|
||||
key: 'session_cookie',
|
||||
label: 'Cookie',
|
||||
type: 'password',
|
||||
placeholder: 'session=MTc2OTYx...',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
helpText: '从浏览器开发者工具复制 session Cookie 值',
|
||||
},
|
||||
],
|
||||
},
|
||||
PROXY_FIELD_GROUP,
|
||||
]
|
||||
},
|
||||
|
||||
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
|
||||
const baseUrl = formData.base_url || providerWebsite || ''
|
||||
|
||||
return {
|
||||
architecture_id: 'nekocode',
|
||||
base_url: baseUrl,
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
session_cookie: formData.session_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||
...proxyData,
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
if (!formData.session_cookie?.trim()) {
|
||||
return '请填写 Session Cookie'
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
formatQuota(quota: number): string {
|
||||
// NekoCode 的余额单位是美元
|
||||
if (quota >= 1) {
|
||||
return `$${quota.toFixed(2)}`
|
||||
}
|
||||
return `$${quota.toFixed(4)}`
|
||||
},
|
||||
|
||||
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
|
||||
const items: BalanceExtraItem[] = []
|
||||
|
||||
// 每日配额(天卡)- 显示进度条和倒计时
|
||||
if (extra.daily_quota_limit !== undefined && extra.daily_remaining_quota !== undefined) {
|
||||
const limit = Number(extra.daily_quota_limit)
|
||||
const remaining = Number(extra.daily_remaining_quota)
|
||||
const percent = limit > 0 ? Math.round((remaining / limit) * 100) : 0
|
||||
|
||||
// 计算刷新时间戳
|
||||
let resetsAt: number | undefined
|
||||
if (extra.effective_start_date) {
|
||||
try {
|
||||
// effective_start_date 是订阅开始时间,每日配额在每天的这个时间刷新
|
||||
const startDate = new Date(extra.effective_start_date)
|
||||
const now = new Date()
|
||||
// 找到下一个刷新时间点(今天或明天的同一时间)
|
||||
const todayReset = new Date(now)
|
||||
todayReset.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), 0)
|
||||
if (todayReset <= now) {
|
||||
// 已过今天的刷新时间,设为明天
|
||||
todayReset.setDate(todayReset.getDate() + 1)
|
||||
}
|
||||
resetsAt = Math.floor(todayReset.getTime() / 1000)
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: '天',
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt,
|
||||
})
|
||||
}
|
||||
|
||||
// 套餐到期时间 - 显示倒计时
|
||||
if (extra.effective_end_date) {
|
||||
try {
|
||||
const endDate = new Date(extra.effective_end_date)
|
||||
const now = new Date()
|
||||
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const resetsAt = Math.floor(endDate.getTime() / 1000)
|
||||
const percent = Math.min(100, Math.max(0, Math.round((daysLeft / 30) * 100)))
|
||||
|
||||
items.push({
|
||||
label: '月',
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt,
|
||||
})
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
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: '站点地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite
|
||||
? `${providerWebsite}`
|
||||
: '请填写站点地址',
|
||||
required: !providerWebsite,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
key: 'cookie',
|
||||
label: 'Cookie',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
sensitive: true,
|
||||
helpText: '填写 Cookie 后支持自动签到',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
layout: 'inline',
|
||||
fields: [
|
||||
{
|
||||
key: 'api_key',
|
||||
label: '访问令牌 (API Key)',
|
||||
type: 'password',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
sensitive: true,
|
||||
flex: 3,
|
||||
},
|
||||
{
|
||||
key: 'user_id',
|
||||
label: '用户 ID',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
required: false,
|
||||
flex: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
PROXY_FIELD_GROUP,
|
||||
]
|
||||
},
|
||||
|
||||
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',
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
|
||||
api_key: formData.api_key?.trim() || '',
|
||||
user_id: formData.user_id?.trim() || '',
|
||||
cookie: formData.cookie?.trim() || '',
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
api_key: config?.connector?.credentials?.api_key || '',
|
||||
user_id: config?.connector?.credentials?.user_id || '',
|
||||
cookie: config?.connector?.credentials?.cookie || '',
|
||||
...proxyData,
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
const hasApiKey = !!formData.api_key?.trim()
|
||||
const hasCookie = !!formData.cookie?.trim()
|
||||
const hasUserId = !!formData.user_id?.trim()
|
||||
|
||||
// api_key 和 cookie 至少需要一个
|
||||
if (!hasApiKey && !hasCookie) {
|
||||
return '访问令牌和 Cookie 至少需要填写一个'
|
||||
}
|
||||
|
||||
// 使用 api_key 时必须提供 user_id,使用 cookie 时 user_id 可选
|
||||
if (hasApiKey && !hasCookie && !hasUserId) {
|
||||
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)}`
|
||||
},
|
||||
|
||||
onFieldChange(fieldKey: string, value: any, formData: Record<string, any>): void {
|
||||
// 当 cookie 变化且 user_id 为空时,尝试从 cookie 解析 user_id
|
||||
if (fieldKey === 'cookie' && value && !formData.user_id?.trim()) {
|
||||
const result = parseSessionCookie(value)
|
||||
if (result.userId) {
|
||||
formData.user_id = result.userId
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
interface SessionCookieResult {
|
||||
userId: string | null
|
||||
username: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Cookie 字符串中解析用户 ID 和用户名
|
||||
*
|
||||
* New API 的 session cookie 格式:
|
||||
* base64(timestamp|gob_base64|signature)
|
||||
*
|
||||
* gob 数据中包含 id 和 username 字段
|
||||
*
|
||||
* 支持两种输入:
|
||||
* 1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
|
||||
* 2. 仅 session 值: "MTc2ODc4..."
|
||||
*/
|
||||
function parseSessionCookie(cookie: string): SessionCookieResult {
|
||||
const result: SessionCookieResult = { userId: null, username: null }
|
||||
|
||||
try {
|
||||
// 提取 session 值
|
||||
let sessionValue = cookie.trim()
|
||||
if (sessionValue.includes('session=')) {
|
||||
const match = sessionValue.match(/session=([^;]+)/)
|
||||
if (match) {
|
||||
sessionValue = match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// URL-safe base64 解码
|
||||
// 补充 padding
|
||||
const padding = 4 - (sessionValue.length % 4)
|
||||
if (padding !== 4) {
|
||||
sessionValue += '='.repeat(padding)
|
||||
}
|
||||
|
||||
// 将 URL-safe base64 转为标准 base64
|
||||
const standardBase64 = sessionValue.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const decoded = atob(standardBase64)
|
||||
|
||||
// 分割: timestamp|gob_base64|signature
|
||||
const parts = decoded.split('|')
|
||||
if (parts.length < 2) {
|
||||
return result
|
||||
}
|
||||
|
||||
// 解码 gob 数据(第二部分)
|
||||
let gobB64 = parts[1]
|
||||
const gobPadding = 4 - (gobB64.length % 4)
|
||||
if (gobPadding !== 4) {
|
||||
gobB64 += '='.repeat(gobPadding)
|
||||
}
|
||||
const gobStandardB64 = gobB64.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const gobData = atob(gobStandardB64)
|
||||
|
||||
// 解析 gob 编码的 id 字段
|
||||
// 查找 "\x02id\x03int" 模式,后面跟着 gob 编码的整数
|
||||
const idIntPattern = '\x02id\x03int'
|
||||
const idIdx = gobData.indexOf(idIntPattern)
|
||||
if (idIdx !== -1) {
|
||||
// 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
|
||||
const valueStart = idIdx + 7 + 2
|
||||
if (valueStart < gobData.length) {
|
||||
// 读取第一个字节,检查是否是 00(正数标记)
|
||||
const firstByte = gobData.charCodeAt(valueStart)
|
||||
if (firstByte === 0) {
|
||||
// 下一个字节是长度标记
|
||||
const marker = gobData.charCodeAt(valueStart + 1)
|
||||
if (marker >= 0x80) {
|
||||
// 负的表示长度: 256 - marker = 字节数
|
||||
const length = 256 - marker
|
||||
if (valueStart + 2 + length <= gobData.length) {
|
||||
// 读取 length 字节,大端序转整数
|
||||
let val = 0
|
||||
for (let i = 0; i < length; i++) {
|
||||
val = (val << 8) | gobData.charCodeAt(valueStart + 2 + i)
|
||||
}
|
||||
// gob zigzag 解码:正整数用 2*n 编码
|
||||
result.userId = (val >> 1).toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 gob 编码的 username 字段
|
||||
// 查找 "\x08username\x06string" 模式,后面跟着长度和字符串值
|
||||
const usernamePattern = '\x08username\x06string'
|
||||
const usernameIdx = gobData.indexOf(usernamePattern)
|
||||
if (usernameIdx !== -1) {
|
||||
// 跳过 pattern (16字节) 和类型标记 (3字节)
|
||||
const lengthPos = usernameIdx + usernamePattern.length + 3
|
||||
if (lengthPos < gobData.length) {
|
||||
const lengthByte = gobData.charCodeAt(lengthPos)
|
||||
const valueStart = lengthPos + 1
|
||||
// 长度 < 128 表示直接长度编码
|
||||
if (lengthByte < 128 && valueStart + lengthByte <= gobData.length) {
|
||||
result.username = gobData.substring(valueStart, valueStart + lengthByte)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
}
|
||||
508
frontend/src/features/providers/auth-templates/schema-utils.ts
Normal file
508
frontend/src/features/providers/auth-templates/schema-utils.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Schema-Driven 工具函数
|
||||
*
|
||||
* 根据后端 JSON Schema(含 x-* 扩展字段)动态生成表单、构建请求、解析配置和格式化显示。
|
||||
* 替代原有的手写模板文件(new-api.ts, anyrouter.ts 等)。
|
||||
*/
|
||||
|
||||
import type { ConnectorAuthType, SaveConfigRequest } from '@/api/providerOps'
|
||||
import type {
|
||||
AuthTemplateField,
|
||||
AuthTemplateFieldGroup,
|
||||
BalanceExtraItem,
|
||||
} from './types'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
import { executeFieldHook } from './field-hooks'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 后端 credentials_schema 的类型 */
|
||||
export interface CredentialsSchema {
|
||||
type: 'object'
|
||||
properties: Record<string, SchemaProperty>
|
||||
required?: string[]
|
||||
'x-field-groups'?: SchemaFieldGroup[]
|
||||
'x-auth-type'?: string
|
||||
'x-auth-method'?: string
|
||||
'x-validation'?: SchemaValidation[]
|
||||
'x-quota-divisor'?: number | null
|
||||
'x-currency'?: string
|
||||
'x-default-base-url'?: string
|
||||
'x-balance-extra-format'?: BalanceExtraFormat[]
|
||||
'x-field-hooks'?: Record<string, { action: string; target: string }>
|
||||
}
|
||||
|
||||
interface SchemaProperty {
|
||||
type: string
|
||||
title?: string
|
||||
description?: string
|
||||
'x-sensitive'?: boolean
|
||||
'x-input-type'?: string
|
||||
'x-default-value'?: string
|
||||
'x-help'?: string
|
||||
}
|
||||
|
||||
interface SchemaFieldGroup {
|
||||
fields: string[]
|
||||
layout?: 'inline' | 'vertical'
|
||||
'x-flex'?: Record<string, number>
|
||||
'x-help'?: string
|
||||
}
|
||||
|
||||
interface SchemaValidation {
|
||||
type: 'required' | 'any_required' | 'conditional_required'
|
||||
fields?: string[]
|
||||
message: string
|
||||
/** conditional_required: 当此字段有值时 */
|
||||
if?: string
|
||||
/** conditional_required: 除非此字段有值 */
|
||||
unless?: string
|
||||
/** conditional_required: 则这些字段必填 */
|
||||
then?: string[]
|
||||
}
|
||||
|
||||
interface BalanceExtraFormat {
|
||||
label: string
|
||||
type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry'
|
||||
/** window_limit: extra 中的字段名 */
|
||||
source?: string
|
||||
/** window_limit: 单位除数 */
|
||||
unit_divisor?: number
|
||||
/** daily_quota / weekly_spent: limit 字段名 */
|
||||
source_limit?: string
|
||||
/** daily_quota: remaining 字段名 */
|
||||
source_remaining?: string
|
||||
/** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */
|
||||
source_start_date?: string
|
||||
/** weekly_spent: spent 字段名 */
|
||||
source_spent?: string
|
||||
/** weekly_spent: resets_at 字段名 */
|
||||
source_resets_at?: string
|
||||
/** monthly_expiry: 到期日期字段名 */
|
||||
source_end_date?: string
|
||||
}
|
||||
|
||||
// ==================== Schema -> 表单字段 ====================
|
||||
|
||||
/**
|
||||
* 从 schema 生成表单字段分组
|
||||
*/
|
||||
export function schemaToFieldGroups(
|
||||
schema: CredentialsSchema,
|
||||
providerWebsite?: string,
|
||||
): AuthTemplateFieldGroup[] {
|
||||
const groups: AuthTemplateFieldGroup[] = []
|
||||
const fieldGroups = schema['x-field-groups']
|
||||
const properties = schema.properties
|
||||
const defaultBaseUrl = schema['x-default-base-url']
|
||||
|
||||
if (fieldGroups && fieldGroups.length > 0) {
|
||||
for (const group of fieldGroups) {
|
||||
const fields: AuthTemplateField[] = []
|
||||
for (const fieldKey of group.fields) {
|
||||
const prop = properties[fieldKey]
|
||||
if (!prop) continue
|
||||
fields.push(propertyToField(fieldKey, prop, schema, providerWebsite, defaultBaseUrl, group))
|
||||
}
|
||||
if (fields.length === 0) continue
|
||||
|
||||
const result: AuthTemplateFieldGroup = { fields }
|
||||
if (group.layout === 'inline') {
|
||||
result.layout = 'inline'
|
||||
}
|
||||
groups.push(result)
|
||||
}
|
||||
} else {
|
||||
// 没有分组定义,按 properties 顺序逐个展示
|
||||
for (const [key, prop] of Object.entries(properties)) {
|
||||
groups.push({
|
||||
fields: [propertyToField(key, prop, schema, providerWebsite, defaultBaseUrl)],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 追加代理配置
|
||||
groups.push(PROXY_FIELD_GROUP)
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function propertyToField(
|
||||
key: string,
|
||||
prop: SchemaProperty,
|
||||
schema: CredentialsSchema,
|
||||
providerWebsite?: string,
|
||||
defaultBaseUrl?: string,
|
||||
group?: SchemaFieldGroup,
|
||||
): AuthTemplateField {
|
||||
const isSensitive = prop['x-sensitive'] === true
|
||||
const inputType = prop['x-input-type']
|
||||
|
||||
let fieldType: AuthTemplateField['type'] = 'text'
|
||||
if (inputType === 'password' || isSensitive) {
|
||||
fieldType = 'password'
|
||||
}
|
||||
|
||||
// base_url 特殊处理 placeholder
|
||||
let placeholder = ''
|
||||
if (key === 'base_url') {
|
||||
placeholder = providerWebsite || defaultBaseUrl || ''
|
||||
}
|
||||
|
||||
// 是否必填
|
||||
const isRequired = schema.required?.includes(key) ?? false
|
||||
|
||||
// flex 值
|
||||
let flex: number | undefined
|
||||
if (group?.['x-flex']?.[key]) {
|
||||
flex = group['x-flex'][key]
|
||||
}
|
||||
|
||||
// helpText
|
||||
let helpText: string | undefined
|
||||
if (prop['x-help']) {
|
||||
helpText = prop['x-help']
|
||||
} else if (group?.['x-help'] && group.fields.length === 1 && group.fields[0] === key) {
|
||||
helpText = group['x-help']
|
||||
}
|
||||
|
||||
const field: AuthTemplateField = {
|
||||
key,
|
||||
label: prop.title || key,
|
||||
type: fieldType,
|
||||
placeholder,
|
||||
required: key === 'base_url' ? !providerWebsite && !defaultBaseUrl : isRequired,
|
||||
sensitive: isSensitive,
|
||||
}
|
||||
|
||||
if (flex) field.flex = flex
|
||||
if (helpText) field.helpText = helpText
|
||||
if (prop['x-default-value']) field.defaultValue = prop['x-default-value']
|
||||
|
||||
return field
|
||||
}
|
||||
|
||||
// ==================== 构建请求 ====================
|
||||
|
||||
/**
|
||||
* 从 schema 和表单数据构建 SaveConfigRequest
|
||||
*/
|
||||
export function buildRequestFromSchema(
|
||||
schema: CredentialsSchema,
|
||||
architectureId: string,
|
||||
formData: Record<string, any>,
|
||||
providerWebsite?: string,
|
||||
): SaveConfigRequest {
|
||||
const baseUrl = formData.base_url || providerWebsite || schema['x-default-base-url'] || ''
|
||||
const authType = schema['x-auth-type'] || 'api_key'
|
||||
|
||||
// 构建 credentials:除 base_url 和代理字段外的所有 schema 属性
|
||||
const credentials: Record<string, any> = {}
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (key === 'base_url') continue
|
||||
const v = formData[key]
|
||||
credentials[key] = typeof v === 'string' ? v.trim() : v ?? ''
|
||||
}
|
||||
|
||||
return {
|
||||
architecture_id: architectureId,
|
||||
base_url: baseUrl,
|
||||
connector: {
|
||||
auth_type: authType as ConnectorAuthType,
|
||||
config: {
|
||||
...(schema['x-auth-method'] ? { auth_method: schema['x-auth-method'] } : {}),
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials,
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 解析配置 ====================
|
||||
|
||||
/**
|
||||
* 从已有配置解析表单数据
|
||||
*/
|
||||
export function parseConfigFromSchema(
|
||||
schema: CredentialsSchema,
|
||||
config: any,
|
||||
): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
const result: Record<string, any> = {
|
||||
base_url: config?.base_url || '',
|
||||
...proxyData,
|
||||
}
|
||||
|
||||
// 从 credentials 中提取各 schema 属性
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (key === 'base_url') continue
|
||||
result[key] = config?.connector?.credentials?.[key] || ''
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ==================== 验证 ====================
|
||||
|
||||
/**
|
||||
* 根据 schema 验证表单数据
|
||||
* @returns 错误消息,无错误返回 null
|
||||
*/
|
||||
export function validateFromSchema(
|
||||
schema: CredentialsSchema,
|
||||
formData: Record<string, any>,
|
||||
): string | null {
|
||||
const validations = schema['x-validation']
|
||||
if (!validations) return null
|
||||
|
||||
for (const rule of validations) {
|
||||
switch (rule.type) {
|
||||
case 'required': {
|
||||
if (!rule.fields) break
|
||||
for (const field of rule.fields) {
|
||||
if (!formData[field]?.trim?.()) {
|
||||
return rule.message
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'any_required': {
|
||||
if (!rule.fields) break
|
||||
const hasAny = rule.fields.some((f) => !!formData[f]?.trim?.())
|
||||
if (!hasAny) {
|
||||
return rule.message
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'conditional_required': {
|
||||
const ifField = rule.if
|
||||
const unlessField = rule.unless
|
||||
const thenFields = rule.then
|
||||
if (!ifField || !thenFields) break
|
||||
|
||||
const ifHasValue = !!formData[ifField]?.trim?.()
|
||||
const unlessHasValue = unlessField ? !!formData[unlessField]?.trim?.() : false
|
||||
|
||||
if (ifHasValue && !unlessHasValue) {
|
||||
for (const field of thenFields) {
|
||||
if (!formData[field]?.trim?.()) {
|
||||
return rule.message
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ==================== Quota 格式化 ====================
|
||||
|
||||
/**
|
||||
* 根据 schema 格式化 quota 显示
|
||||
*/
|
||||
export function formatQuotaFromSchema(
|
||||
schema: CredentialsSchema,
|
||||
quota: number,
|
||||
): string {
|
||||
const divisor = schema['x-quota-divisor']
|
||||
const currency = schema['x-currency'] || 'USD'
|
||||
|
||||
let value = quota
|
||||
if (divisor) {
|
||||
value = quota / divisor
|
||||
}
|
||||
|
||||
const symbol = currency === 'USD' ? '$' : currency
|
||||
if (value >= 1) {
|
||||
return `${symbol}${value.toFixed(2)}`
|
||||
}
|
||||
return `${symbol}${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
// ==================== Balance Extra 格式化 ====================
|
||||
|
||||
/**
|
||||
* 根据 schema 格式化余额附加信息
|
||||
*/
|
||||
export function formatBalanceExtraFromSchema(
|
||||
schema: CredentialsSchema,
|
||||
extra: Record<string, any>,
|
||||
): BalanceExtraItem[] {
|
||||
const formats = schema['x-balance-extra-format']
|
||||
if (!formats) return []
|
||||
|
||||
const items: BalanceExtraItem[] = []
|
||||
|
||||
for (const fmt of formats) {
|
||||
switch (fmt.type) {
|
||||
case 'window_limit': {
|
||||
const item = formatWindowLimitItem(extra, fmt)
|
||||
if (item) items.push(item)
|
||||
break
|
||||
}
|
||||
case 'daily_quota': {
|
||||
const item = formatDailyQuotaItem(extra, fmt)
|
||||
if (item) items.push(item)
|
||||
break
|
||||
}
|
||||
case 'weekly_spent': {
|
||||
const item = formatWeeklySpentItem(extra, fmt)
|
||||
if (item) items.push(item)
|
||||
break
|
||||
}
|
||||
case 'monthly_expiry': {
|
||||
const item = formatMonthlyExpiryItem(extra, fmt)
|
||||
if (item) items.push(item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function formatWindowLimitItem(
|
||||
extra: Record<string, any>,
|
||||
fmt: BalanceExtraFormat,
|
||||
): BalanceExtraItem | null {
|
||||
if (!fmt.source) return null
|
||||
const limit = extra[fmt.source]
|
||||
if (!limit || limit.remaining === undefined || limit.limit === undefined || limit.limit === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const percent = Math.round((limit.remaining / limit.limit) * 100)
|
||||
const divisor = fmt.unit_divisor || 1
|
||||
const remaining = (limit.remaining / divisor).toFixed(2)
|
||||
const total = (limit.limit / divisor).toFixed(2)
|
||||
|
||||
return {
|
||||
label: fmt.label,
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt: limit.resets_at,
|
||||
tooltip: `$${remaining} / $${total}`,
|
||||
}
|
||||
}
|
||||
|
||||
function formatDailyQuotaItem(
|
||||
extra: Record<string, any>,
|
||||
fmt: BalanceExtraFormat,
|
||||
): BalanceExtraItem | null {
|
||||
const limitKey = fmt.source_limit || 'daily_quota_limit'
|
||||
const remainingKey = fmt.source_remaining || 'daily_remaining_quota'
|
||||
|
||||
const limit = Number(extra[limitKey])
|
||||
const remaining = Number(extra[remainingKey])
|
||||
|
||||
if (extra[limitKey] === undefined || extra[remainingKey] === undefined) return null
|
||||
if (!limit) return null
|
||||
|
||||
const percent = Math.round((remaining / limit) * 100)
|
||||
|
||||
// 从 source_start_date 计算下次重置时间
|
||||
let resetsAt: number | undefined
|
||||
const startDateKey = fmt.source_start_date
|
||||
if (startDateKey && extra[startDateKey]) {
|
||||
try {
|
||||
const startDate = new Date(extra[startDateKey])
|
||||
const now = new Date()
|
||||
const todayReset = new Date(now)
|
||||
todayReset.setHours(startDate.getHours(), startDate.getMinutes(), startDate.getSeconds(), 0)
|
||||
if (todayReset <= now) {
|
||||
todayReset.setDate(todayReset.getDate() + 1)
|
||||
}
|
||||
resetsAt = Math.floor(todayReset.getTime() / 1000)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
label: fmt.label,
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt,
|
||||
}
|
||||
}
|
||||
|
||||
function formatMonthlyExpiryItem(
|
||||
extra: Record<string, any>,
|
||||
fmt: BalanceExtraFormat,
|
||||
): BalanceExtraItem | null {
|
||||
const endDateKey = fmt.source_end_date || 'effective_end_date'
|
||||
if (!extra[endDateKey]) return null
|
||||
|
||||
try {
|
||||
const endDate = new Date(extra[endDateKey])
|
||||
const now = new Date()
|
||||
const daysLeft = Math.ceil((endDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const resetsAt = Math.floor(endDate.getTime() / 1000)
|
||||
const percent = Math.min(100, Math.max(0, Math.round((daysLeft / 30) * 100)))
|
||||
|
||||
return {
|
||||
label: fmt.label,
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatWeeklySpentItem(
|
||||
extra: Record<string, any>,
|
||||
fmt: BalanceExtraFormat,
|
||||
): BalanceExtraItem | null {
|
||||
const limitKey = fmt.source_limit || 'weekly_limit'
|
||||
const spentKey = fmt.source_spent || 'weekly_spent'
|
||||
const resetsAtKey = fmt.source_resets_at || 'weekly_resets_at'
|
||||
|
||||
const limit = extra[limitKey]
|
||||
const spent = extra[spentKey]
|
||||
|
||||
if (limit === undefined || limit <= 0 || spent === undefined) return null
|
||||
|
||||
const remaining = Math.max(0, limit - spent)
|
||||
const percent = Math.round((remaining / limit) * 100)
|
||||
|
||||
return {
|
||||
label: fmt.label,
|
||||
value: `${percent}%`,
|
||||
percent,
|
||||
resetsAt: extra[resetsAtKey],
|
||||
tooltip: `$${remaining.toFixed(2)} / $${(limit as number).toFixed(2)}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Field Hooks ====================
|
||||
|
||||
/**
|
||||
* 处理字段变化时的钩子逻辑
|
||||
*/
|
||||
export function handleSchemaFieldChange(
|
||||
schema: CredentialsSchema,
|
||||
fieldKey: string,
|
||||
value: any,
|
||||
formData: Record<string, any>,
|
||||
): void {
|
||||
const hooks = schema['x-field-hooks']
|
||||
if (!hooks) return
|
||||
|
||||
const hook = hooks[fieldKey]
|
||||
if (!hook) return
|
||||
|
||||
// 目标字段为空时才填充
|
||||
if (formData[hook.target]?.trim?.()) return
|
||||
|
||||
const result = executeFieldHook(hook.action, value)
|
||||
if (result) {
|
||||
formData[hook.target] = result
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
/**
|
||||
* 提供商认证模板类型定义
|
||||
*
|
||||
* 认证模板定义了:
|
||||
* - 需要收集的表单字段
|
||||
* - 如何构建后端请求
|
||||
* - 如何解析已有配置
|
||||
*/
|
||||
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
|
||||
/**
|
||||
* 表单字段类型
|
||||
*/
|
||||
@@ -60,78 +53,6 @@ export interface AuthTemplateFieldGroup {
|
||||
layout?: 'vertical' | 'inline'
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证结果数据
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* 格式化余额 extra 信息(如窗口限额等)
|
||||
* 返回一个数组,每个元素包含 label 和 value
|
||||
* @param extra 余额 extra 字段
|
||||
*/
|
||||
formatBalanceExtra?(extra: Record<string, any>): BalanceExtraItem[]
|
||||
|
||||
/**
|
||||
* 字段值变化时的回调,可用于联动填充其他字段
|
||||
* @param fieldKey 变化的字段 key
|
||||
* @param value 新值
|
||||
* @param formData 当前表单数据(可修改)
|
||||
*/
|
||||
onFieldChange?(fieldKey: string, value: any, formData: Record<string, any>): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额附加信息项
|
||||
*/
|
||||
@@ -148,20 +69,6 @@ export interface BalanceExtraItem {
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证模板注册表类型
|
||||
*/
|
||||
export interface AuthTemplateRegistry {
|
||||
/** 获取所有模板 */
|
||||
getAll(): AuthTemplate[]
|
||||
/** 根据 ID 获取模板 */
|
||||
get(id: string): AuthTemplate | undefined
|
||||
/** 获取默认模板 */
|
||||
getDefault(): AuthTemplate
|
||||
/** 注册模板 */
|
||||
register(template: AuthTemplate): void
|
||||
}
|
||||
|
||||
// ==================== 通用字段定义 ====================
|
||||
|
||||
/**
|
||||
@@ -188,30 +95,6 @@ export const PROXY_FIELD_GROUP: AuthTemplateFieldGroup = {
|
||||
toggleKey: 'proxy_enabled',
|
||||
}
|
||||
|
||||
// 兼容旧代码的字段导出
|
||||
export const PROXY_URL_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_url',
|
||||
label: '代理地址',
|
||||
type: 'text',
|
||||
placeholder: 'http://proxy:port 或 socks5://',
|
||||
required: false,
|
||||
}
|
||||
export const PROXY_USERNAME_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_username',
|
||||
label: '用户名',
|
||||
type: 'text',
|
||||
placeholder: '可选',
|
||||
required: false,
|
||||
}
|
||||
export const PROXY_PASSWORD_FIELD: AuthTemplateField = {
|
||||
key: 'proxy_password',
|
||||
label: '密码',
|
||||
type: 'password',
|
||||
placeholder: '可选',
|
||||
required: false,
|
||||
sensitive: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建代理配置(仅代理节点模式)
|
||||
*
|
||||
@@ -253,6 +136,3 @@ export function parseProxyConfig(config: any): Record<string, any> {
|
||||
proxy_node_id: '',
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧的导出
|
||||
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* YesCode 认证模板
|
||||
*
|
||||
* 适用于 YesCode 中转站:
|
||||
* - 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
* - 余额单位直接是美元
|
||||
* - subscription_balance: 套餐每日额度
|
||||
* - weekly_limit: 周限额(用完后套餐额度也无法使用)
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
import { PROXY_FIELD_GROUP, buildProxyConfig, parseProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 格式化限额显示(百分比格式)
|
||||
*/
|
||||
function formatLimitPercent(remaining: number, limit: number): { text: string; percent: number } {
|
||||
if (!limit || limit === 0) {
|
||||
return { text: '-', percent: 0 }
|
||||
}
|
||||
|
||||
const percent = Math.round((remaining / limit) * 100)
|
||||
return { text: `${percent}%`, percent }
|
||||
}
|
||||
|
||||
export const yescodeTemplate: AuthTemplate = {
|
||||
id: 'yescode',
|
||||
name: 'YesCode',
|
||||
description: '适用于 YesCode 中转站,使用 Cookie 认证',
|
||||
|
||||
getFields(providerWebsite?: string): AuthTemplateFieldGroup[] {
|
||||
return [
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: '站点地址',
|
||||
type: 'text',
|
||||
placeholder: providerWebsite || 'https://co.yes.vg',
|
||||
required: !providerWebsite,
|
||||
helpText: '通常为 https://co.yes.vg',
|
||||
},
|
||||
{
|
||||
key: 'auth_cookie',
|
||||
label: 'Auth Cookie',
|
||||
type: 'password',
|
||||
placeholder: 'yescode_auth=eyJhbGciOiJI...; yescode_csrf=...',
|
||||
required: true,
|
||||
sensitive: true,
|
||||
helpText: '从浏览器开发者工具复制 Cookie(包含 yescode_auth 和 yescode_csrf)',
|
||||
},
|
||||
],
|
||||
},
|
||||
PROXY_FIELD_GROUP,
|
||||
]
|
||||
},
|
||||
|
||||
buildRequest(formData: Record<string, any>, providerWebsite?: string): SaveConfigRequest {
|
||||
const baseUrl = formData.base_url || providerWebsite || ''
|
||||
|
||||
return {
|
||||
architecture_id: 'yescode',
|
||||
base_url: baseUrl,
|
||||
connector: {
|
||||
auth_type: 'cookie',
|
||||
config: {
|
||||
...buildProxyConfig(formData),
|
||||
},
|
||||
credentials: {
|
||||
auth_cookie: formData.auth_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
const proxyData = parseProxyConfig(config?.connector?.config)
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
|
||||
...proxyData,
|
||||
}
|
||||
},
|
||||
|
||||
validate(formData: Record<string, any>): string | null {
|
||||
if (!formData.auth_cookie?.trim()) {
|
||||
return '请填写 Auth Cookie'
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
formatQuota(quota: number): string {
|
||||
// YesCode 的余额单位直接是美元
|
||||
if (quota >= 1) {
|
||||
return `$${quota.toFixed(2)}`
|
||||
}
|
||||
return `$${quota.toFixed(4)}`
|
||||
},
|
||||
|
||||
formatBalanceExtra(extra: Record<string, any>): BalanceExtraItem[] {
|
||||
const items: BalanceExtraItem[] = []
|
||||
|
||||
const weeklyLimit = extra.weekly_limit
|
||||
const weeklySpent = extra.weekly_spent
|
||||
const dailyLimit = extra.daily_limit
|
||||
const dailySpent = extra.daily_spent
|
||||
const dailyResetsAt = extra.daily_resets_at
|
||||
const weeklyResetsAt = extra.weekly_resets_at
|
||||
|
||||
// 天限
|
||||
if (dailyLimit !== undefined && dailyLimit > 0 && dailySpent !== undefined) {
|
||||
const dailyRemaining = Math.max(0, dailyLimit - dailySpent)
|
||||
const { text, percent } = formatLimitPercent(dailyRemaining, dailyLimit)
|
||||
items.push({
|
||||
label: '天',
|
||||
value: text,
|
||||
percent,
|
||||
resetsAt: dailyResetsAt,
|
||||
tooltip: `$${dailyRemaining.toFixed(2)} / $${(dailyLimit as number).toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 周限
|
||||
if (weeklyLimit !== undefined && weeklyLimit > 0 && weeklySpent !== undefined) {
|
||||
const weeklyRemaining = Math.max(0, weeklyLimit - weeklySpent)
|
||||
const { text, percent } = formatLimitPercent(weeklyRemaining, weeklyLimit)
|
||||
items.push({
|
||||
label: '周',
|
||||
value: text,
|
||||
percent,
|
||||
resetsAt: weeklyResetsAt,
|
||||
tooltip: `$${weeklyRemaining.toFixed(2)} / $${(weeklyLimit as number).toFixed(2)}`,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
}
|
||||
@@ -29,26 +29,26 @@
|
||||
<div class="space-y-2">
|
||||
<Label>认证模板</Label>
|
||||
<Select
|
||||
v-model="selectedTemplateId"
|
||||
@update:model-value="handleTemplateChange"
|
||||
v-model="selectedArchitectureId"
|
||||
@update:model-value="handleArchitectureChange"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择认证模板" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
v-for="arch in architectures"
|
||||
:key="arch.architecture_id"
|
||||
:value="arch.architecture_id"
|
||||
>
|
||||
{{ template.name }}
|
||||
{{ arch.display_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 动态表单字段 -->
|
||||
<template v-if="selectedTemplate">
|
||||
<template v-if="currentSchema">
|
||||
<template
|
||||
v-for="(group, groupIndex) in fieldGroups"
|
||||
:key="groupIndex"
|
||||
@@ -268,14 +268,26 @@ import {
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import { saveProviderOpsConfig, verifyProviderAuth, getProviderOpsConfig, deleteProviderOpsConfig } from '@/api/providerOps'
|
||||
import {
|
||||
getArchitectures,
|
||||
saveProviderOpsConfig,
|
||||
verifyProviderAuth,
|
||||
getProviderOpsConfig,
|
||||
deleteProviderOpsConfig,
|
||||
type ArchitectureInfo,
|
||||
} from '@/api/providerOps'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import type { AuthTemplateFieldGroup } from '../auth-templates/types'
|
||||
import {
|
||||
authTemplateRegistry,
|
||||
type AuthTemplate,
|
||||
type AuthTemplateFieldGroup,
|
||||
} from '../auth-templates'
|
||||
schemaToFieldGroups,
|
||||
buildRequestFromSchema,
|
||||
parseConfigFromSchema,
|
||||
validateFromSchema,
|
||||
formatQuotaFromSchema,
|
||||
handleSchemaFieldChange,
|
||||
type CredentialsSchema,
|
||||
} from '../auth-templates/schema-utils'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
@@ -291,15 +303,19 @@ const emit = defineEmits<{
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
// 敏感字段列表(用于验证和加载配置时的特殊处理)
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookie', 'proxy_password'] as const
|
||||
// 敏感字段检测:根据 schema 动态判断
|
||||
function isSensitiveField(key: string): boolean {
|
||||
if (!currentSchema.value) return false
|
||||
const prop = currentSchema.value.properties[key]
|
||||
return prop?.['x-sensitive'] === true
|
||||
}
|
||||
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
/** 启用代理时加载节点列表(直接调用 store,避免 ref 未挂载时静默失败) */
|
||||
/** 启用代理时加载节点列表 */
|
||||
function handleProxyToggle(toggleKey: string, value: boolean) {
|
||||
formData.value[toggleKey] = value
|
||||
if (value) {
|
||||
@@ -320,30 +336,37 @@ const sensitivePlaceholders = ref<Record<string, string>>({})
|
||||
// 是否有已保存的配置(编辑模式)
|
||||
const hasExistingConfig = ref(false)
|
||||
|
||||
// 模板选择
|
||||
const selectedTemplateId = ref('new_api')
|
||||
// 架构列表(从后端获取)
|
||||
const architectures = ref<ArchitectureInfo[]>([])
|
||||
const architecturesLoaded = ref(false)
|
||||
|
||||
// 当前选择
|
||||
const selectedArchitectureId = ref('new_api')
|
||||
const formData = ref<Record<string, any>>({})
|
||||
|
||||
// 当前架构的 schema
|
||||
const currentSchema = computed<CredentialsSchema | null>(() => {
|
||||
const arch = architectures.value.find((a) => a.architecture_id === selectedArchitectureId.value)
|
||||
return (arch?.credentials_schema as CredentialsSchema) ?? null
|
||||
})
|
||||
|
||||
// 表单是否可以验证(必填字段已填写)
|
||||
const canVerify = computed(() => {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return false
|
||||
const schema = currentSchema.value
|
||||
if (!schema) return false
|
||||
|
||||
// 编辑模式下,敏感字段可以为空(使用已保存的值)
|
||||
let dataToValidate = formData.value
|
||||
if (hasExistingConfig.value) {
|
||||
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证
|
||||
const tempData = { ...formData.value }
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (!tempData[field] && sensitivePlaceholders.value[field]) {
|
||||
tempData[field] = 'placeholder'
|
||||
dataToValidate = { ...formData.value }
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
|
||||
dataToValidate[key] = 'placeholder'
|
||||
}
|
||||
}
|
||||
const error = template.validate(tempData)
|
||||
if (error) return false
|
||||
} else {
|
||||
const error = template.validate(formData.value)
|
||||
if (error) return false
|
||||
}
|
||||
const error = validateFromSchema(schema, dataToValidate)
|
||||
if (error) return false
|
||||
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
return !!effectiveBaseUrl
|
||||
@@ -354,35 +377,26 @@ 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)
|
||||
if (!currentSchema.value) return []
|
||||
return schemaToFieldGroups(currentSchema.value, props.providerWebsite)
|
||||
})
|
||||
|
||||
// Methods
|
||||
function handleTemplateChange() {
|
||||
// 重置表单数据
|
||||
function handleArchitectureChange() {
|
||||
resetFormData()
|
||||
// 重置验证状态
|
||||
verifyStatus.value = null
|
||||
formChanged.value = true
|
||||
}
|
||||
|
||||
function handleFieldChange(fieldKey: string, value: any) {
|
||||
// 标记表单已变动
|
||||
formChanged.value = true
|
||||
|
||||
// 调用模板的 onFieldChange 回调
|
||||
const template = selectedTemplate.value
|
||||
if (template?.onFieldChange) {
|
||||
template.onFieldChange(fieldKey, value, formData.value)
|
||||
// 执行 schema 定义的字段钩子
|
||||
const schema = currentSchema.value
|
||||
if (schema) {
|
||||
handleSchemaFieldChange(schema, fieldKey, value, formData.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +404,6 @@ function handleFieldChange(fieldKey: string, value: any) {
|
||||
watch(
|
||||
formData,
|
||||
() => {
|
||||
// 验证成功后任何修改都需要重新验证
|
||||
if (verifyStatus.value === 'success') {
|
||||
formChanged.value = true
|
||||
}
|
||||
@@ -399,55 +412,52 @@ watch(
|
||||
)
|
||||
|
||||
function resetFormData() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) {
|
||||
const schema = currentSchema.value
|
||||
if (!schema) {
|
||||
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 ?? ''
|
||||
}
|
||||
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||
data[key] = (prop as any)['x-default-value'] ?? ''
|
||||
}
|
||||
// 代理相关默认值
|
||||
data.proxy_enabled = false
|
||||
data.proxy_node_id = ''
|
||||
|
||||
formData.value = data
|
||||
}
|
||||
|
||||
function formatQuota(quota: number): string {
|
||||
const template = selectedTemplate.value
|
||||
if (template?.formatQuota) {
|
||||
return template.formatQuota(quota)
|
||||
const schema = currentSchema.value
|
||||
if (schema) {
|
||||
return formatQuotaFromSchema(schema, quota)
|
||||
}
|
||||
// 默认格式化
|
||||
return quota.toLocaleString()
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return
|
||||
const schema = currentSchema.value
|
||||
if (!schema) 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'
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
|
||||
dataToValidate[key] = 'placeholder'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
const error = validateFromSchema(schema, dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
@@ -457,8 +467,12 @@ async function handleVerify() {
|
||||
isVerifying.value = true
|
||||
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest
|
||||
const request = buildRequestFromSchema(
|
||||
schema,
|
||||
selectedArchitectureId.value,
|
||||
formData.value,
|
||||
props.providerWebsite,
|
||||
)
|
||||
const verifyRequest = {
|
||||
...request,
|
||||
base_url: request.base_url || effectiveBaseUrl,
|
||||
@@ -466,12 +480,10 @@ async function handleVerify() {
|
||||
const result = await verifyProviderAuth(props.providerId, verifyRequest)
|
||||
|
||||
if (result.success) {
|
||||
// 检查是否获取到有效的用户信息和余额
|
||||
const username = result.data?.username?.trim() || result.data?.display_name?.trim()
|
||||
const quota = result.data?.quota
|
||||
|
||||
if (!username || quota === undefined || quota === null) {
|
||||
// 没有获取到必要信息,视为验证失败
|
||||
verifyStatus.value = 'error'
|
||||
const missing: string[] = []
|
||||
if (!username) missing.push('用户信息')
|
||||
@@ -479,8 +491,7 @@ async function handleVerify() {
|
||||
showError(`验证响应缺少: ${missing.join('、')}`)
|
||||
} else {
|
||||
verifyStatus.value = 'success'
|
||||
formChanged.value = false // 验证成功后重置表单变动标记
|
||||
// Toast 提示
|
||||
formChanged.value = false
|
||||
const displayName = result.data?.display_name || result.data?.username
|
||||
showSuccess(`用户: ${displayName} | 余额: ${formatQuota(quota)}`, '验证成功')
|
||||
}
|
||||
@@ -498,26 +509,25 @@ async function handleVerify() {
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const template = selectedTemplate.value
|
||||
if (!template) return
|
||||
const schema = currentSchema.value
|
||||
if (!schema) 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'
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
|
||||
dataToValidate[key] = 'placeholder'
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = template.validate(dataToValidate)
|
||||
const error = validateFromSchema(schema, dataToValidate)
|
||||
if (error) {
|
||||
showError(error)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 base_url
|
||||
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
|
||||
if (!effectiveBaseUrl) {
|
||||
showError('请填写 API 地址')
|
||||
@@ -526,7 +536,12 @@ async function handleSave() {
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
const request = template.buildRequest(formData.value, props.providerWebsite)
|
||||
const request = buildRequestFromSchema(
|
||||
schema,
|
||||
selectedArchitectureId.value,
|
||||
formData.value,
|
||||
props.providerWebsite,
|
||||
)
|
||||
const result = await saveProviderOpsConfig(props.providerId, request)
|
||||
if (result.success) {
|
||||
showSuccess(result.message || '配置已保存', '保存成功')
|
||||
@@ -557,12 +572,11 @@ async function handleClear() {
|
||||
const result = await deleteProviderOpsConfig(props.providerId)
|
||||
if (result.success) {
|
||||
showSuccess(result.message || '认证信息已清除', '清除成功')
|
||||
// 重置状态
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
verifyStatus.value = null
|
||||
formChanged.value = false
|
||||
selectedTemplateId.value = 'new_api'
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
resetFormData()
|
||||
emit('saved')
|
||||
emit('update:open', false)
|
||||
@@ -581,22 +595,21 @@ function loadFromConfig(config: any) {
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api
|
||||
// 根据已保存的 architecture_id 选择对应架构
|
||||
const architectureId = config.architecture_id || 'new_api'
|
||||
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api'
|
||||
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
||||
const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
|
||||
selectedArchitectureId.value = archExists ? architectureId : 'new_api'
|
||||
|
||||
if (template) {
|
||||
const parsedData = template.parseConfig(config)
|
||||
const schema = currentSchema.value
|
||||
if (schema) {
|
||||
const parsedData = parseConfigFromSchema(schema, config)
|
||||
|
||||
// 敏感字段:脱敏值放到 placeholder,表单值设为空
|
||||
sensitivePlaceholders.value = {}
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (parsedData[field]) {
|
||||
// 保存脱敏值作为 placeholder 提示
|
||||
sensitivePlaceholders.value[field] = `${parsedData[field]}`
|
||||
// 表单值设为空
|
||||
parsedData[field] = ''
|
||||
for (const key of Object.keys(schema.properties)) {
|
||||
if (isSensitiveField(key) && parsedData[key]) {
|
||||
sensitivePlaceholders.value[key] = `${parsedData[key]}`
|
||||
parsedData[key] = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,6 +617,17 @@ function loadFromConfig(config: any) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 确保架构列表已加载 */
|
||||
async function ensureArchitecturesLoaded(): Promise<void> {
|
||||
if (architecturesLoaded.value) return
|
||||
try {
|
||||
architectures.value = await getArchitectures()
|
||||
architecturesLoaded.value = true
|
||||
} catch {
|
||||
architectures.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 打开对话框时初始化
|
||||
watch(
|
||||
() => props.open,
|
||||
@@ -612,6 +636,9 @@ watch(
|
||||
verifyStatus.value = null
|
||||
formChanged.value = false
|
||||
|
||||
// 确保架构列表已加载
|
||||
await ensureArchitecturesLoaded()
|
||||
|
||||
// 如果传入了 currentConfig,直接使用
|
||||
if (props.currentConfig?.connector) {
|
||||
loadFromConfig(props.currentConfig)
|
||||
@@ -624,7 +651,6 @@ watch(
|
||||
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,
|
||||
@@ -634,14 +660,13 @@ watch(
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
} catch {
|
||||
// 加载失败,使用默认值
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
resetFormData()
|
||||
} finally {
|
||||
isLoadingConfig.value = false
|
||||
@@ -649,7 +674,7 @@ watch(
|
||||
} else {
|
||||
hasExistingConfig.value = false
|
||||
sensitivePlaceholders.value = {}
|
||||
selectedTemplateId.value = 'new_api'
|
||||
selectedArchitectureId.value = 'new_api'
|
||||
resetFormData()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,9 +698,10 @@ import {
|
||||
API_FORMAT_SHORT
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
|
||||
import { batchQueryBalance, getArchitectures, type ActionResultResponse, type ArchitectureInfo } from '@/api/providerOps'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { authTemplateRegistry, type BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { type BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
import { formatBalanceExtraFromSchema, type CredentialsSchema } from '@/features/providers/auth-templates/schema-utils'
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -715,6 +716,28 @@ const priorityMode = ref<'provider' | 'global_key'>('provider')
|
||||
const providerDrawerOpen = ref(false)
|
||||
const selectedProviderId = ref<string | null>(null)
|
||||
|
||||
// 架构 schema 缓存(用于 balance extra 格式化)
|
||||
const architectureSchemas = ref<Record<string, CredentialsSchema>>({})
|
||||
const architectureSchemasLoaded = ref(false)
|
||||
|
||||
/** 加载架构 schema 缓存 */
|
||||
async function loadArchitectureSchemas() {
|
||||
if (architectureSchemasLoaded.value) return
|
||||
try {
|
||||
const archs: ArchitectureInfo[] = await getArchitectures()
|
||||
const schemas: Record<string, CredentialsSchema> = {}
|
||||
for (const arch of archs) {
|
||||
if (arch.credentials_schema) {
|
||||
schemas[arch.architecture_id] = arch.credentials_schema as CredentialsSchema
|
||||
}
|
||||
}
|
||||
architectureSchemas.value = schemas
|
||||
architectureSchemasLoaded.value = true
|
||||
} catch {
|
||||
// 加载失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
// 扩展操作配置对话框
|
||||
const opsConfigDialogOpen = ref(false)
|
||||
const opsConfigProviderId = ref('')
|
||||
@@ -1109,11 +1132,11 @@ function getProviderBalanceExtra(providerId: string, architectureId?: string): B
|
||||
const extra = data.extra
|
||||
if (!extra) return []
|
||||
|
||||
// 获取对应的模板
|
||||
const template = authTemplateRegistry.get(architectureId)
|
||||
if (!template?.formatBalanceExtra) return []
|
||||
// 从 schema 缓存中获取格式化配置
|
||||
const schema = architectureSchemas.value[architectureId]
|
||||
if (!schema) return []
|
||||
|
||||
return template.formatBalanceExtra(extra)
|
||||
return formatBalanceExtraFromSchema(schema, extra)
|
||||
}
|
||||
|
||||
|
||||
@@ -1313,6 +1336,7 @@ onMounted(() => {
|
||||
loadProviders()
|
||||
loadPriorityMode()
|
||||
loadGlobalModelList()
|
||||
loadArchitectureSchemas()
|
||||
// 每秒更新一次倒计时
|
||||
tickInterval = setInterval(() => {
|
||||
tickCounter.value++
|
||||
|
||||
@@ -40,6 +40,7 @@ class ArchitectureInfo(BaseModel):
|
||||
architecture_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
credentials_schema: dict[str, Any]
|
||||
supported_auth_types: list[dict[str, str]]
|
||||
supported_actions: list[dict[str, Any]]
|
||||
default_connector: str | None
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
Anyrouter 余额查询操作(含自动签到)
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class AnyrouterBalanceAction(BalanceAction):
|
||||
@@ -26,61 +25,7 @@ class AnyrouterBalanceAction(BalanceAction):
|
||||
display_name = "查询余额(含自动签到)"
|
||||
description = "查询账户余额,同时自动签到"
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询"""
|
||||
endpoint = self.config.get("endpoint", "/api/user/self")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await client.request(method, endpoint)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return self._make_error_result(
|
||||
ActionStatus.PARSE_ERROR,
|
||||
"响应不是有效的 JSON",
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return self._handle_http_error(response, data)
|
||||
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
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)}",
|
||||
)
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
@@ -100,23 +45,6 @@ class AnyrouterBalanceAction(BalanceAction):
|
||||
currency=self.config.get("currency", "USD"),
|
||||
)
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行自动签到(始终执行)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
余额查询操作抽象基类
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
@@ -20,8 +21,11 @@ class BalanceAction(ProviderAction):
|
||||
"""
|
||||
余额查询操作抽象基类
|
||||
|
||||
子类必须实现 _do_query_balance() 方法来处理特定平台的余额查询逻辑。
|
||||
子类可选实现 _do_checkin() 方法来在查询余额前执行签到。
|
||||
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
|
||||
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
|
||||
|
||||
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
|
||||
显示 "Cookie 已失效" 而非 "认证失败"。
|
||||
"""
|
||||
|
||||
action_type = ProviderActionType.QUERY_BALANCE
|
||||
@@ -29,6 +33,9 @@ class BalanceAction(ProviderAction):
|
||||
description = "查询账户余额信息"
|
||||
default_cache_ttl = 86400 # 24 小时
|
||||
|
||||
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
|
||||
_cookie_auth: bool = False
|
||||
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询(模板方法)
|
||||
@@ -67,10 +74,13 @@ class BalanceAction(ProviderAction):
|
||||
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询(子类必须实现)
|
||||
执行余额查询
|
||||
|
||||
默认实现处理通用的请求/响应/错误处理流程。
|
||||
子类只需实现 _parse_balance() 即可。
|
||||
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
@@ -78,8 +88,109 @@ class BalanceAction(ProviderAction):
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 BalanceInfo
|
||||
"""
|
||||
endpoint = self.config.get("endpoint", "/api/user/self")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await client.request(method, endpoint)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return self._make_error_result(
|
||||
ActionStatus.PARSE_ERROR,
|
||||
"响应不是有效的 JSON",
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return self._handle_http_error(response, data)
|
||||
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
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)}",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""
|
||||
解析余额数据(子类必须实现)
|
||||
|
||||
Args:
|
||||
data: API 响应 JSON 数据
|
||||
|
||||
Returns:
|
||||
BalanceInfo 对象
|
||||
"""
|
||||
pass
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""
|
||||
处理 HTTP 错误响应
|
||||
|
||||
Cookie 认证的子类设置 _cookie_auth = True 即可获得友好的错误提示,
|
||||
无需再逐个重写此方法。
|
||||
"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
msg = "Cookie 已失效,请重新配置" if self._cookie_auth else "认证失败"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 403:
|
||||
msg = "Cookie 已失效或无权限" if self._cookie_auth else "无权限访问"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 404:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NOT_SUPPORTED, "功能未开放", 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,
|
||||
)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行签到(子类可选实现)
|
||||
|
||||
@@ -2,13 +2,10 @@
|
||||
Cubence 余额查询操作
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class CubenceBalanceAction(BalanceAction):
|
||||
@@ -24,84 +21,7 @@ class CubenceBalanceAction(BalanceAction):
|
||||
display_name = "查询余额(含窗口限额)"
|
||||
description = "查询账户余额和窗口限额信息"
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行 Cubence 余额查询(实现抽象方法)"""
|
||||
endpoint = self.config.get("endpoint", "/api/v1/dashboard/overview")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
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)
|
||||
|
||||
# 检查业务状态码
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
# 解析余额信息
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
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 _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应(Cubence 专用)"""
|
||||
status_code = response.status_code
|
||||
|
||||
# Cubence 使用 Cookie 认证,提供更友好的错误提示
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
# 其他错误使用基类处理
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析 Cubence 余额信息"""
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
NekoCode 余额查询操作
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NekoCodeBalanceAction(BalanceAction):
|
||||
@@ -27,61 +24,7 @@ class NekoCodeBalanceAction(BalanceAction):
|
||||
display_name = "查询余额"
|
||||
description = "查询 NekoCode 账户余额和订阅信息"
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询"""
|
||||
endpoint = self.config.get("endpoint", "/api/usage/summary")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await client.request(method, endpoint)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return self._make_error_result(
|
||||
ActionStatus.PARSE_ERROR,
|
||||
"响应不是有效的 JSON",
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return self._handle_http_error(response, data)
|
||||
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
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)}",
|
||||
)
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
@@ -160,23 +103,6 @@ class NekoCodeBalanceAction(BalanceAction):
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
|
||||
)
|
||||
|
||||
return super()._handle_http_error(response, raw_data)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
New API 余额查询操作
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NewApiBalanceAction(BalanceAction):
|
||||
@@ -25,66 +24,6 @@ class NewApiBalanceAction(BalanceAction):
|
||||
display_name = "查询余额"
|
||||
description = "查询 New API 账户余额信息"
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(实现抽象方法)"""
|
||||
endpoint = self.config.get("endpoint", "/api/user/self")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
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)
|
||||
|
||||
# 检查业务状态码
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
# 解析余额信息
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
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,
|
||||
|
||||
@@ -168,6 +168,12 @@ class YesCodeBalanceAction(BalanceAction):
|
||||
display_name = "查询余额(含每周限额)"
|
||||
description = "查询账户余额和每周限额信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""YesCode 不使用基类的 _do_query_balance,此方法不会被调用"""
|
||||
raise NotImplementedError("YesCode 使用自定义 _do_query_balance")
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
|
||||
import time
|
||||
|
||||
@@ -12,7 +12,6 @@ from src.services.provider_ops.architectures.cubence import CubenceArchitecture
|
||||
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
|
||||
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture
|
||||
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
|
||||
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
|
||||
__all__ = [
|
||||
@@ -24,6 +23,5 @@ __all__ = [
|
||||
"GenericApiArchitecture",
|
||||
"NekoCodeArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"OneApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
]
|
||||
|
||||
@@ -18,9 +18,9 @@ from src.services.provider_ops.actions import (
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# acw_sc__v2 算法常量
|
||||
@@ -93,31 +93,6 @@ def _compute_acw_sc_v2(arg1: str) -> str:
|
||||
return result
|
||||
|
||||
|
||||
def _extract_session_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 session 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
|
||||
2. 仅 session 值: "MTc2ODc4..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
session cookie 的值
|
||||
"""
|
||||
# 如果包含 "session=",说明是完整 Cookie 字符串
|
||||
if "session=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("session="):
|
||||
return part[8:] # 去掉 "session=" 前缀
|
||||
# 否则认为直接是 session 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
从 session cookie 中解析用户 ID 和用户名
|
||||
@@ -138,7 +113,7 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
try:
|
||||
# 先提取 session 值
|
||||
session_cookie = _extract_session_from_cookie(cookie_input)
|
||||
session_cookie = extract_cookie_value(cookie_input, "session")
|
||||
# 1. URL-safe base64 解码外层
|
||||
padding = 4 - len(session_cookie) % 4
|
||||
if padding != 4:
|
||||
@@ -292,7 +267,7 @@ class AnyrouterConnector(ProviderConnector):
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = _extract_session_from_cookie(session_cookie)
|
||||
self._session_cookie = extract_cookie_value(session_cookie, "session")
|
||||
|
||||
# 解析 user_id
|
||||
self._user_id, _ = _parse_session_user_id(session_cookie)
|
||||
@@ -341,13 +316,36 @@ class AnyrouterConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://anyrouter.top",
|
||||
},
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["session_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://anyrouter.top",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["session_cookie"],
|
||||
"message": "请填写 Session Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +439,7 @@ class AnyrouterArchitecture(ProviderArchitecture):
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = _extract_session_from_cookie(cookie_input)
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
cookies.append(f"session={session_value}")
|
||||
|
||||
# 从 session 解析 user_id 并添加 New-Api-User header
|
||||
@@ -454,33 +452,8 @@ class AnyrouterArchitecture(ProviderArchitecture):
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Anyrouter 验证响应"""
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# Anyrouter 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("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=None,
|
||||
)
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
|
||||
@@ -241,6 +241,9 @@ class ProviderArchitecture(ABC):
|
||||
display_name: str = ""
|
||||
description: str = ""
|
||||
|
||||
# 设为 True 时不在架构列表 API 中返回(内部使用的架构)
|
||||
hidden: bool = False
|
||||
|
||||
# 支持的 Connector 类型列表(按优先级排序)
|
||||
supported_connectors: list[type[ProviderConnector]] = []
|
||||
|
||||
@@ -259,7 +262,7 @@ class ProviderArchitecture(ABC):
|
||||
"""
|
||||
self.config = config or {}
|
||||
|
||||
# ==================== 认证验证相关方法(子类必须实现) ====================
|
||||
# ==================== 认证验证相关方法 ====================
|
||||
|
||||
@abstractmethod
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
@@ -267,26 +270,9 @@ class ProviderArchitecture(ABC):
|
||||
获取凭据字段定义(JSON Schema 格式)
|
||||
|
||||
子类必须实现此方法定义需要的凭据字段。
|
||||
这个 schema 可用于:
|
||||
1. 前端表单生成(如果需要动态渲染)
|
||||
2. 凭据验证
|
||||
3. 文档生成
|
||||
|
||||
Returns:
|
||||
JSON Schema 格式的字段定义
|
||||
|
||||
Example:
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "访问令牌",
|
||||
},
|
||||
},
|
||||
"required": ["api_key"],
|
||||
}
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -322,7 +308,6 @@ class ProviderArchitecture(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
@@ -331,7 +316,8 @@ class ProviderArchitecture(ABC):
|
||||
"""
|
||||
解析认证验证响应
|
||||
|
||||
子类必须实现此方法解析响应。
|
||||
默认实现处理通用的 {"success": bool, "data": {...}} 格式。
|
||||
子类可重写 _auth_fail_message() 和 _build_verify_result() 进行自定义。
|
||||
|
||||
Args:
|
||||
status_code: HTTP 状态码
|
||||
@@ -340,7 +326,61 @@ class ProviderArchitecture(ABC):
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
pass
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(401))
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(403))
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# 解析通用响应格式
|
||||
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 self._build_verify_result(user_data, data)
|
||||
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""
|
||||
获取认证失败消息
|
||||
|
||||
子类可重写以提供自定义消息(如 Cookie 认证场景)。
|
||||
"""
|
||||
if status_code == 401:
|
||||
return "认证失败:无效的凭据"
|
||||
return "认证失败:权限不足"
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""
|
||||
从用户数据构建验证结果
|
||||
|
||||
默认实现提取 username, display_name, email, quota, used_quota, request_count。
|
||||
子类可重写以自定义字段提取。
|
||||
"""
|
||||
known_fields = (
|
||||
"username",
|
||||
"display_name",
|
||||
"email",
|
||||
"quota",
|
||||
"used_quota",
|
||||
"request_count",
|
||||
)
|
||||
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 known_fields},
|
||||
)
|
||||
|
||||
# ==================== 可选的钩子方法 ====================
|
||||
|
||||
|
||||
@@ -16,31 +16,7 @@ from src.services.provider_ops.architectures.base import (
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
def _extract_token_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 token 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "token=xxx; other=yyy; ..."
|
||||
2. 仅 token 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 token 值
|
||||
|
||||
Returns:
|
||||
token cookie 的值
|
||||
"""
|
||||
# 如果包含 "token=",说明是完整 Cookie 字符串
|
||||
if "token=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("token="):
|
||||
return part[6:] # 去掉 "token=" 前缀
|
||||
# 否则认为直接是 token 值
|
||||
return cookie_string.strip()
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
|
||||
|
||||
class CubenceConnector(ProviderConnector):
|
||||
@@ -66,7 +42,7 @@ class CubenceConnector(ProviderConnector):
|
||||
return False
|
||||
|
||||
# 提取纯 token 值
|
||||
self._token_cookie = _extract_token_from_cookie(token_cookie)
|
||||
self._token_cookie = extract_cookie_value(token_cookie, "token")
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
@@ -93,13 +69,50 @@ class CubenceConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://cubence.com",
|
||||
},
|
||||
"token_cookie": {
|
||||
"type": "string",
|
||||
"title": "Token Cookie",
|
||||
"description": "从浏览器复制的 token Cookie 值(JWT 格式)",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["token_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["token_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://cubence.com",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["token_cookie"],
|
||||
"message": "请填写 Token Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "5h",
|
||||
"type": "window_limit",
|
||||
"source": "five_hour_limit",
|
||||
"unit_divisor": 1000000,
|
||||
},
|
||||
{
|
||||
"label": "周",
|
||||
"type": "window_limit",
|
||||
"source": "weekly_limit",
|
||||
"unit_divisor": 1000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -161,30 +174,21 @@ class CubenceArchitecture(ProviderArchitecture):
|
||||
# 添加 token Cookie
|
||||
cookie_input = credentials.get("token_cookie")
|
||||
if cookie_input:
|
||||
token_value = _extract_token_from_cookie(cookie_input)
|
||||
token_value = extract_cookie_value(cookie_input, "token")
|
||||
headers["Cookie"] = f"token={token_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Cubence 验证响应"""
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
|
||||
# Cubence 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""Cubence 自定义字段提取(user/balance/subscription_limits)"""
|
||||
user_info = user_data.get("user", {})
|
||||
balance_info = user_data.get("balance", {})
|
||||
subscription_limits = user_data.get("subscription_limits", {})
|
||||
|
||||
@@ -50,7 +50,6 @@ from src.services.provider_ops.actions import (
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
@@ -112,13 +111,35 @@ class GenericApiKeyConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "提供商的 API Key",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["api_key"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["api_key"]},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["api_key"],
|
||||
"message": "请填写 API Key",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +156,7 @@ class GenericApiArchitecture(ProviderArchitecture):
|
||||
architecture_id = "generic_api"
|
||||
display_name = "通用 API"
|
||||
description = "可配置的通用 API 架构,适用于各种中转站"
|
||||
hidden = True
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
GenericApiKeyConnector,
|
||||
@@ -180,48 +202,3 @@ class GenericApiArchitecture(ProviderArchitecture):
|
||||
headers[header_name] = api_key
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析通用 API 验证响应"""
|
||||
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}")
|
||||
|
||||
# 尝试解析通用响应格式
|
||||
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",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,34 +17,10 @@ from src.services.provider_ops.architectures.base import (
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _extract_session_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 session 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "session=xxx; other=xxx; ..."
|
||||
2. 仅 session 值: "MTc2OTYx..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
session cookie 的值
|
||||
"""
|
||||
# 如果包含 "session=",说明是完整 Cookie 字符串
|
||||
if "session=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("session="):
|
||||
return part[8:] # 去掉 "session=" 前缀
|
||||
# 否则认为直接是 session 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
class NekoCodeConnector(ProviderConnector):
|
||||
"""
|
||||
NekoCode 专用连接器
|
||||
@@ -68,7 +44,7 @@ class NekoCodeConnector(ProviderConnector):
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = _extract_session_from_cookie(session_cookie)
|
||||
self._session_cookie = extract_cookie_value(session_cookie, "session")
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
@@ -95,13 +71,50 @@ class NekoCodeConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://nekocode.ai",
|
||||
},
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["session_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://nekocode.ai",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["session_cookie"],
|
||||
"message": "请填写 Session Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "天",
|
||||
"type": "daily_quota",
|
||||
"source_limit": "daily_quota_limit",
|
||||
"source_remaining": "daily_remaining_quota",
|
||||
"source_start_date": "effective_start_date",
|
||||
},
|
||||
{
|
||||
"label": "月",
|
||||
"type": "monthly_expiry",
|
||||
"source_end_date": "effective_end_date",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +185,7 @@ class NekoCodeArchitecture(ProviderArchitecture):
|
||||
# 添加 Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
session_value = _extract_session_from_cookie(cookie_input)
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
# 构建 client 参数
|
||||
@@ -218,31 +231,21 @@ class NekoCodeArchitecture(ProviderArchitecture):
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = _extract_session_from_cookie(cookie_input)
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 NekoCode 验证响应(/api/user/self + _usage_summary)"""
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# NekoCode 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""NekoCode 自定义字段提取(合并 _usage_summary 天卡数据)"""
|
||||
# 转换余额字符串为数字
|
||||
balance = user_data.get("balance")
|
||||
try:
|
||||
@@ -252,11 +255,10 @@ class NekoCodeArchitecture(ProviderArchitecture):
|
||||
|
||||
# 从 prepare_verify_config 获取的 _usage_summary 数据(天卡信息)
|
||||
extra: dict[str, Any] = {}
|
||||
usage_summary = data.get("_usage_summary", {})
|
||||
usage_summary = (raw_data or {}).get("_usage_summary", {})
|
||||
subscription = usage_summary.get("subscription", {})
|
||||
|
||||
if subscription:
|
||||
# 转换字符串为数字
|
||||
daily_limit = subscription.get("daily_quota_limit")
|
||||
daily_remaining = subscription.get("daily_remaining_quota")
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,6 @@ from src.services.provider_ops.actions import (
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
@@ -90,10 +89,17 @@ class NewApiConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "访问令牌 (API Key)",
|
||||
"description": "New API 的访问令牌,与 Cookie 二选一",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
@@ -104,9 +110,47 @@ class NewApiConnector(ProviderConnector):
|
||||
"type": "string",
|
||||
"title": "Cookie",
|
||||
"description": "用于 Cookie 认证,与访问令牌二选一",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{
|
||||
"fields": ["cookie"],
|
||||
"x-help": "从浏览器开发者工具复制完整 Cookie",
|
||||
},
|
||||
{
|
||||
"layout": "inline",
|
||||
"fields": ["api_key", "user_id"],
|
||||
"x-flex": {"api_key": 3, "user_id": 1},
|
||||
},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "any_required",
|
||||
"fields": ["api_key", "cookie"],
|
||||
"message": "访问令牌和 Cookie 至少需要填写一个",
|
||||
},
|
||||
{
|
||||
"type": "conditional_required",
|
||||
"if": "api_key",
|
||||
"unless": "cookie",
|
||||
"then": ["user_id"],
|
||||
"message": "使用访问令牌时,用户 ID 不能为空",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
"x-field-hooks": {
|
||||
"cookie": {
|
||||
"action": "parse_new_api_user_id",
|
||||
"target": "user_id",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -180,48 +224,3 @@ class NewApiArchitecture(ProviderArchitecture):
|
||||
headers["Cookie"] = cookie
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 New API 验证响应"""
|
||||
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}")
|
||||
|
||||
# New API 响应格式: {"success": true, "data": {...}}
|
||||
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",
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""
|
||||
One API 架构
|
||||
|
||||
针对 One API 风格的中转站优化的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import NewApiBalanceAction, ProviderAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
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: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._api_key: str | None = 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]] = [
|
||||
NewApiBalanceAction,
|
||||
]
|
||||
|
||||
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()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""One API 验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""构建 One API 的验证请求 Headers"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
api_key = credentials.get("api_key", "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 One API 验证响应"""
|
||||
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}")
|
||||
|
||||
# One API 响应格式: {"success": true, "data": {...}}
|
||||
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",
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -121,13 +121,52 @@ class YesCodeConnector(ProviderConnector):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://co.yes.vg",
|
||||
},
|
||||
"auth_cookie": {
|
||||
"type": "string",
|
||||
"title": "Auth Cookie",
|
||||
"description": "从浏览器复制的 Cookie(包含 yescode_auth 和 yescode_csrf)",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["auth_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["auth_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://co.yes.vg",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["auth_cookie"],
|
||||
"message": "请填写 Auth Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "天",
|
||||
"type": "weekly_spent",
|
||||
"source_limit": "daily_limit",
|
||||
"source_spent": "daily_spent",
|
||||
"source_resets_at": "daily_resets_at",
|
||||
},
|
||||
{
|
||||
"label": "周",
|
||||
"type": "weekly_spent",
|
||||
"source_limit": "weekly_limit",
|
||||
"source_spent": "weekly_spent",
|
||||
"source_resets_at": "weekly_resets_at",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from src.services.provider_ops.architectures import (
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
YesCodeArchitecture,
|
||||
)
|
||||
@@ -58,7 +57,6 @@ class ArchitectureRegistry:
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
]
|
||||
|
||||
@@ -133,8 +131,8 @@ class ArchitectureRegistry:
|
||||
return list(self._architectures.keys())
|
||||
|
||||
def to_dict_list(self) -> list[dict]:
|
||||
"""获取所有架构的字典表示(用于 API 响应)"""
|
||||
return [arch.to_dict() for arch in self._architectures.values()]
|
||||
"""获取所有架构的字典表示(用于 API 响应,隐藏 hidden 架构)"""
|
||||
return [arch.to_dict() for arch in self._architectures.values() if not arch.hidden]
|
||||
|
||||
|
||||
# 全局注册表实例
|
||||
|
||||
26
src/services/provider_ops/utils.py
Normal file
26
src/services/provider_ops/utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Provider Ops 通用工具函数
|
||||
"""
|
||||
|
||||
|
||||
def extract_cookie_value(cookie_string: str, key: str) -> str:
|
||||
"""
|
||||
从 Cookie 字符串中提取指定 key 的值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "key=xxx; other=yyy; ..."
|
||||
2. 仅值: "MTc2ODc4..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或直接的值
|
||||
key: 要提取的 Cookie key
|
||||
|
||||
Returns:
|
||||
对应的值
|
||||
"""
|
||||
if f"{key}=" in cookie_string:
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith(f"{key}="):
|
||||
return part[len(key) + 1 :]
|
||||
return cookie_string.strip()
|
||||
Reference in New Issue
Block a user