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:
fawney19
2026-02-13 16:41:30 +08:00
parent 7e68882872
commit d6b39babf5
30 changed files with 1233 additions and 1835 deletions

View File

@@ -45,6 +45,7 @@ export interface ArchitectureInfo {
architecture_id: string architecture_id: string
display_name: string display_name: string
description: string description: string
credentials_schema: Record<string, any>
supported_auth_types: Array<{ supported_auth_types: Array<{
type: string type: string
display_name: string display_name: string

View File

@@ -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)}`
},
}

View File

@@ -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
},
}

View 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
}
}

View File

@@ -1,74 +1,11 @@
/** /**
* 提供商认证模板注册表 * 提供商认证模板
* *
* 集中管理所有认证模板。 * Schema-Driven 模式:后端返回 JSON Schema含 x-* 扩展字段),
* * 前端根据 schema 动态渲染表单、构建请求、格式化显示。
* ## 添加新模板的步骤 * 新增架构只需后端一个文件,前端零改动。
*
* 1. 在 `auth-templates/` 目录下创建新的模板文件(如 `my-api.ts`
* 2. 实现 `AuthTemplate` 接口
* 3. 在本文件中导入并注册到 `templates` 数组
*
* ## 模板需要实现的内容
*
* - `id`: 模板唯一标识(对应后端的 architecture_id
* - `name`: 显示名称
* - `description`: 描述文本
* - `getFields()`: 返回表单字段定义
* - `buildRequest()`: 构建后端 API 请求
* - `parseConfig()`: 从已有配置解析表单数据
* - `validate()`: 验证表单数据
* - `formatQuota()`: (可选)格式化 quota 显示
*/ */
import type { AuthTemplate, AuthTemplateRegistry } from './types'
import { 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 * from './types'
export { anyrouterTemplate } from './anyrouter' export * from './schema-utils'
export { cubenceTemplate } from './cubence' export { executeFieldHook } from './field-hooks'
export { nekocodeTemplate } from './nekocode'
export { newApiTemplate } from './new-api'
export { yescodeTemplate } from './yescode'

View File

@@ -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
},
}

View File

@@ -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
}
}

View 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
}
}

View File

@@ -1,14 +1,7 @@
/** /**
* 提供商认证模板类型定义 * 提供商认证模板类型定义
*
* 认证模板定义了:
* - 需要收集的表单字段
* - 如何构建后端请求
* - 如何解析已有配置
*/ */
import type { SaveConfigRequest } from '@/api/providerOps'
/** /**
* 表单字段类型 * 表单字段类型
*/ */
@@ -60,78 +53,6 @@ export interface AuthTemplateFieldGroup {
layout?: 'vertical' | 'inline' 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 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', 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: '', proxy_node_id: '',
} }
} }
// 兼容旧的导出
export const PROXY_FIELD: AuthTemplateField = PROXY_URL_FIELD

View File

@@ -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
},
}

View File

@@ -29,26 +29,26 @@
<div class="space-y-2"> <div class="space-y-2">
<Label>认证模板</Label> <Label>认证模板</Label>
<Select <Select
v-model="selectedTemplateId" v-model="selectedArchitectureId"
@update:model-value="handleTemplateChange" @update:model-value="handleArchitectureChange"
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择认证模板" /> <SelectValue placeholder="选择认证模板" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
v-for="template in templates" v-for="arch in architectures"
:key="template.id" :key="arch.architecture_id"
:value="template.id" :value="arch.architecture_id"
> >
{{ template.name }} {{ arch.display_name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<!-- 动态表单字段 --> <!-- 动态表单字段 -->
<template v-if="selectedTemplate"> <template v-if="currentSchema">
<template <template
v-for="(group, groupIndex) in fieldGroups" v-for="(group, groupIndex) in fieldGroups"
:key="groupIndex" :key="groupIndex"
@@ -268,14 +268,26 @@ import {
SelectValue, SelectValue,
Switch, Switch,
} from '@/components/ui' } 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 { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
import type { AuthTemplateFieldGroup } from '../auth-templates/types'
import { import {
authTemplateRegistry, schemaToFieldGroups,
type AuthTemplate, buildRequestFromSchema,
type AuthTemplateFieldGroup, parseConfigFromSchema,
} from '../auth-templates' validateFromSchema,
formatQuotaFromSchema,
handleSchemaFieldChange,
type CredentialsSchema,
} from '../auth-templates/schema-utils'
import ProxyNodeSelect from './ProxyNodeSelect.vue' import ProxyNodeSelect from './ProxyNodeSelect.vue'
import { useProxyNodesStore } from '@/stores/proxy-nodes' import { useProxyNodesStore } from '@/stores/proxy-nodes'
@@ -291,15 +303,19 @@ const emit = defineEmits<{
(e: 'saved'): void (e: 'saved'): void
}>() }>()
// 敏感字段列表(用于验证和加载配置时的特殊处理) // 敏感字段检测:根据 schema 动态判断
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookie', 'proxy_password'] as const 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 { success: showSuccess, error: showError } = useToast()
const { confirmDanger } = useConfirm() const { confirmDanger } = useConfirm()
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null) const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
const proxyNodesStore = useProxyNodesStore() const proxyNodesStore = useProxyNodesStore()
/** 启用代理时加载节点列表(直接调用 store避免 ref 未挂载时静默失败) */ /** 启用代理时加载节点列表 */
function handleProxyToggle(toggleKey: string, value: boolean) { function handleProxyToggle(toggleKey: string, value: boolean) {
formData.value[toggleKey] = value formData.value[toggleKey] = value
if (value) { if (value) {
@@ -320,30 +336,37 @@ const sensitivePlaceholders = ref<Record<string, string>>({})
// 是否有已保存的配置(编辑模式) // 是否有已保存的配置(编辑模式)
const hasExistingConfig = ref(false) 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>>({}) 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 canVerify = computed(() => {
const template = selectedTemplate.value const schema = currentSchema.value
if (!template) return false if (!schema) return false
// 编辑模式下,敏感字段可以为空(使用已保存的值) // 编辑模式下,敏感字段可以为空(使用已保存的值)
let dataToValidate = formData.value
if (hasExistingConfig.value) { if (hasExistingConfig.value) {
// 创建一个临时数据,把空的敏感字段填充为占位值以通过验证 dataToValidate = { ...formData.value }
const tempData = { ...formData.value } for (const key of Object.keys(schema.properties)) {
for (const field of SENSITIVE_FIELDS) { if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
if (!tempData[field] && sensitivePlaceholders.value[field]) { dataToValidate[key] = 'placeholder'
tempData[field] = '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 const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
return !!effectiveBaseUrl return !!effectiveBaseUrl
@@ -354,35 +377,26 @@ const canSave = computed(() => {
return verifyStatus.value === 'success' && !formChanged.value 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[]>(() => { const fieldGroups = computed<AuthTemplateFieldGroup[]>(() => {
if (!selectedTemplate.value) return [] if (!currentSchema.value) return []
return selectedTemplate.value.getFields(props.providerWebsite) return schemaToFieldGroups(currentSchema.value, props.providerWebsite)
}) })
// Methods // Methods
function handleTemplateChange() { function handleArchitectureChange() {
// 重置表单数据
resetFormData() resetFormData()
// 重置验证状态
verifyStatus.value = null verifyStatus.value = null
formChanged.value = true formChanged.value = true
} }
function handleFieldChange(fieldKey: string, value: any) { function handleFieldChange(fieldKey: string, value: any) {
// 标记表单已变动
formChanged.value = true formChanged.value = true
// 调用模板的 onFieldChange 回调 // 执行 schema 定义的字段钩子
const template = selectedTemplate.value const schema = currentSchema.value
if (template?.onFieldChange) { if (schema) {
template.onFieldChange(fieldKey, value, formData.value) handleSchemaFieldChange(schema, fieldKey, value, formData.value)
} }
} }
@@ -390,7 +404,6 @@ function handleFieldChange(fieldKey: string, value: any) {
watch( watch(
formData, formData,
() => { () => {
// 验证成功后任何修改都需要重新验证
if (verifyStatus.value === 'success') { if (verifyStatus.value === 'success') {
formChanged.value = true formChanged.value = true
} }
@@ -399,55 +412,52 @@ watch(
) )
function resetFormData() { function resetFormData() {
const template = selectedTemplate.value const schema = currentSchema.value
if (!template) { if (!schema) {
formData.value = {} formData.value = {}
return return
} }
// 初始化表单数据,设置默认值 // 初始化表单数据
const data: Record<string, any> = {} const data: Record<string, any> = {}
const groups = template.getFields(props.providerWebsite) for (const [key, prop] of Object.entries(schema.properties)) {
data[key] = (prop as any)['x-default-value'] ?? ''
for (const group of groups) {
for (const field of group.fields) {
data[field.key] = field.defaultValue ?? ''
}
} }
// 代理相关默认值
data.proxy_enabled = false
data.proxy_node_id = ''
formData.value = data formData.value = data
} }
function formatQuota(quota: number): string { function formatQuota(quota: number): string {
const template = selectedTemplate.value const schema = currentSchema.value
if (template?.formatQuota) { if (schema) {
return template.formatQuota(quota) return formatQuotaFromSchema(schema, quota)
} }
// 默认格式化
return quota.toLocaleString() return quota.toLocaleString()
} }
async function handleVerify() { async function handleVerify() {
const template = selectedTemplate.value const schema = currentSchema.value
if (!template) return if (!schema) return
// 验证表单(编辑模式下敏感字段可以为空) // 验证表单(编辑模式下敏感字段可以为空)
let dataToValidate = formData.value let dataToValidate = formData.value
if (hasExistingConfig.value) { if (hasExistingConfig.value) {
dataToValidate = { ...formData.value } dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) { for (const key of Object.keys(schema.properties)) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) { if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[field] = 'placeholder' dataToValidate[key] = 'placeholder'
} }
} }
} }
const error = template.validate(dataToValidate) const error = validateFromSchema(schema, dataToValidate)
if (error) { if (error) {
showError(error) showError(error)
return return
} }
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) { if (!effectiveBaseUrl) {
showError('请填写 API 地址') showError('请填写 API 地址')
@@ -457,8 +467,12 @@ async function handleVerify() {
isVerifying.value = true isVerifying.value = true
try { try {
const request = template.buildRequest(formData.value, props.providerWebsite) const request = buildRequestFromSchema(
// 确保 base_url 是有效字符串,用于 VerifyAuthRequest schema,
selectedArchitectureId.value,
formData.value,
props.providerWebsite,
)
const verifyRequest = { const verifyRequest = {
...request, ...request,
base_url: request.base_url || effectiveBaseUrl, base_url: request.base_url || effectiveBaseUrl,
@@ -466,12 +480,10 @@ async function handleVerify() {
const result = await verifyProviderAuth(props.providerId, verifyRequest) const result = await verifyProviderAuth(props.providerId, verifyRequest)
if (result.success) { if (result.success) {
// 检查是否获取到有效的用户信息和余额
const username = result.data?.username?.trim() || result.data?.display_name?.trim() const username = result.data?.username?.trim() || result.data?.display_name?.trim()
const quota = result.data?.quota const quota = result.data?.quota
if (!username || quota === undefined || quota === null) { if (!username || quota === undefined || quota === null) {
// 没有获取到必要信息,视为验证失败
verifyStatus.value = 'error' verifyStatus.value = 'error'
const missing: string[] = [] const missing: string[] = []
if (!username) missing.push('用户信息') if (!username) missing.push('用户信息')
@@ -479,8 +491,7 @@ async function handleVerify() {
showError(`验证响应缺少: ${missing.join('、')}`) showError(`验证响应缺少: ${missing.join('、')}`)
} else { } else {
verifyStatus.value = 'success' verifyStatus.value = 'success'
formChanged.value = false // 验证成功后重置表单变动标记 formChanged.value = false
// Toast 提示
const displayName = result.data?.display_name || result.data?.username const displayName = result.data?.display_name || result.data?.username
showSuccess(`用户: ${displayName} | 余额: ${formatQuota(quota)}`, '验证成功') showSuccess(`用户: ${displayName} | 余额: ${formatQuota(quota)}`, '验证成功')
} }
@@ -498,26 +509,25 @@ async function handleVerify() {
} }
async function handleSave() { async function handleSave() {
const template = selectedTemplate.value const schema = currentSchema.value
if (!template) return if (!schema) return
// 验证表单(编辑模式下敏感字段可以为空) // 验证表单
let dataToValidate = formData.value let dataToValidate = formData.value
if (hasExistingConfig.value) { if (hasExistingConfig.value) {
dataToValidate = { ...formData.value } dataToValidate = { ...formData.value }
for (const field of SENSITIVE_FIELDS) { for (const key of Object.keys(schema.properties)) {
if (!dataToValidate[field] && sensitivePlaceholders.value[field]) { if (isSensitiveField(key) && !dataToValidate[key] && sensitivePlaceholders.value[key]) {
dataToValidate[field] = 'placeholder' dataToValidate[key] = 'placeholder'
} }
} }
} }
const error = template.validate(dataToValidate) const error = validateFromSchema(schema, dataToValidate)
if (error) { if (error) {
showError(error) showError(error)
return return
} }
// 检查 base_url
const effectiveBaseUrl = formData.value.base_url || props.providerWebsite const effectiveBaseUrl = formData.value.base_url || props.providerWebsite
if (!effectiveBaseUrl) { if (!effectiveBaseUrl) {
showError('请填写 API 地址') showError('请填写 API 地址')
@@ -526,7 +536,12 @@ async function handleSave() {
isSaving.value = true isSaving.value = true
try { 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) const result = await saveProviderOpsConfig(props.providerId, request)
if (result.success) { if (result.success) {
showSuccess(result.message || '配置已保存', '保存成功') showSuccess(result.message || '配置已保存', '保存成功')
@@ -557,12 +572,11 @@ async function handleClear() {
const result = await deleteProviderOpsConfig(props.providerId) const result = await deleteProviderOpsConfig(props.providerId)
if (result.success) { if (result.success) {
showSuccess(result.message || '认证信息已清除', '清除成功') showSuccess(result.message || '认证信息已清除', '清除成功')
// 重置状态
hasExistingConfig.value = false hasExistingConfig.value = false
sensitivePlaceholders.value = {} sensitivePlaceholders.value = {}
verifyStatus.value = null verifyStatus.value = null
formChanged.value = false formChanged.value = false
selectedTemplateId.value = 'new_api' selectedArchitectureId.value = 'new_api'
resetFormData() resetFormData()
emit('saved') emit('saved')
emit('update:open', false) emit('update:open', false)
@@ -581,22 +595,21 @@ function loadFromConfig(config: any) {
hasExistingConfig.value = true hasExistingConfig.value = true
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api // 根据已保存的 architecture_id 选择对应架构
const architectureId = config.architecture_id || 'new_api' const architectureId = config.architecture_id || 'new_api'
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api' const archExists = architectures.value.some((a) => a.architecture_id === architectureId)
const template = authTemplateRegistry.get(selectedTemplateId.value) selectedArchitectureId.value = archExists ? architectureId : 'new_api'
if (template) { const schema = currentSchema.value
const parsedData = template.parseConfig(config) if (schema) {
const parsedData = parseConfigFromSchema(schema, config)
// 敏感字段:脱敏值放到 placeholder表单值设为空 // 敏感字段:脱敏值放到 placeholder表单值设为空
sensitivePlaceholders.value = {} sensitivePlaceholders.value = {}
for (const field of SENSITIVE_FIELDS) { for (const key of Object.keys(schema.properties)) {
if (parsedData[field]) { if (isSensitiveField(key) && parsedData[key]) {
// 保存脱敏值作为 placeholder 提示 sensitivePlaceholders.value[key] = `${parsedData[key]}`
sensitivePlaceholders.value[field] = `${parsedData[field]}` parsedData[key] = ''
// 表单值设为空
parsedData[field] = ''
} }
} }
@@ -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( watch(
() => props.open, () => props.open,
@@ -612,6 +636,9 @@ watch(
verifyStatus.value = null verifyStatus.value = null
formChanged.value = false formChanged.value = false
// 确保架构列表已加载
await ensureArchitecturesLoaded()
// 如果传入了 currentConfig直接使用 // 如果传入了 currentConfig直接使用
if (props.currentConfig?.connector) { if (props.currentConfig?.connector) {
loadFromConfig(props.currentConfig) loadFromConfig(props.currentConfig)
@@ -624,7 +651,6 @@ watch(
try { try {
const config = await getProviderOpsConfig(props.providerId) const config = await getProviderOpsConfig(props.providerId)
if (config.is_configured && config.architecture_id) { if (config.is_configured && config.architecture_id) {
// 构建与 loadFromConfig 兼容的格式
const configData = { const configData = {
architecture_id: config.architecture_id, architecture_id: config.architecture_id,
base_url: config.base_url, base_url: config.base_url,
@@ -634,14 +660,13 @@ watch(
} else { } else {
hasExistingConfig.value = false hasExistingConfig.value = false
sensitivePlaceholders.value = {} sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api' selectedArchitectureId.value = 'new_api'
resetFormData() resetFormData()
} }
} catch { } catch {
// 加载失败,使用默认值
hasExistingConfig.value = false hasExistingConfig.value = false
sensitivePlaceholders.value = {} sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api' selectedArchitectureId.value = 'new_api'
resetFormData() resetFormData()
} finally { } finally {
isLoadingConfig.value = false isLoadingConfig.value = false
@@ -649,7 +674,7 @@ watch(
} else { } else {
hasExistingConfig.value = false hasExistingConfig.value = false
sensitivePlaceholders.value = {} sensitivePlaceholders.value = {}
selectedTemplateId.value = 'new_api' selectedArchitectureId.value = 'new_api'
resetFormData() resetFormData()
} }
} }

View File

@@ -698,9 +698,10 @@ import {
API_FORMAT_SHORT API_FORMAT_SHORT
} from '@/api/endpoints' } from '@/api/endpoints'
import { adminApi } from '@/api/admin' 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 { 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 { error: showError, success: showSuccess } = useToast()
const { confirmDanger } = useConfirm() const { confirmDanger } = useConfirm()
@@ -715,6 +716,28 @@ const priorityMode = ref<'provider' | 'global_key'>('provider')
const providerDrawerOpen = ref(false) const providerDrawerOpen = ref(false)
const selectedProviderId = ref<string | null>(null) 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 opsConfigDialogOpen = ref(false)
const opsConfigProviderId = ref('') const opsConfigProviderId = ref('')
@@ -1109,11 +1132,11 @@ function getProviderBalanceExtra(providerId: string, architectureId?: string): B
const extra = data.extra const extra = data.extra
if (!extra) return [] if (!extra) return []
// 获取对应的模板 // 从 schema 缓存中获取格式化配置
const template = authTemplateRegistry.get(architectureId) const schema = architectureSchemas.value[architectureId]
if (!template?.formatBalanceExtra) return [] if (!schema) return []
return template.formatBalanceExtra(extra) return formatBalanceExtraFromSchema(schema, extra)
} }
@@ -1313,6 +1336,7 @@ onMounted(() => {
loadProviders() loadProviders()
loadPriorityMode() loadPriorityMode()
loadGlobalModelList() loadGlobalModelList()
loadArchitectureSchemas()
// 每秒更新一次倒计时 // 每秒更新一次倒计时
tickInterval = setInterval(() => { tickInterval = setInterval(() => {
tickCounter.value++ tickCounter.value++

View File

@@ -40,6 +40,7 @@ class ArchitectureInfo(BaseModel):
architecture_id: str architecture_id: str
display_name: str display_name: str
description: str description: str
credentials_schema: dict[str, Any]
supported_auth_types: list[dict[str, str]] supported_auth_types: list[dict[str, str]]
supported_actions: list[dict[str, Any]] supported_actions: list[dict[str, Any]]
default_connector: str | None default_connector: str | None

View File

@@ -2,14 +2,13 @@
Anyrouter 余额查询操作(含自动签到) Anyrouter 余额查询操作(含自动签到)
""" """
import time
from typing import Any from typing import Any
import httpx import httpx
from src.core.logger import logger from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction 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): class AnyrouterBalanceAction(BalanceAction):
@@ -26,61 +25,7 @@ class AnyrouterBalanceAction(BalanceAction):
display_name = "查询余额(含自动签到)" display_name = "查询余额(含自动签到)"
description = "查询账户余额,同时自动签到" description = "查询账户余额,同时自动签到"
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult: _cookie_auth = True
"""执行余额查询"""
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)}",
)
def _parse_balance(self, data: Any) -> BalanceInfo: def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息""" """解析余额信息"""
@@ -100,23 +45,6 @@ class AnyrouterBalanceAction(BalanceAction):
currency=self.config.get("currency", "USD"), 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: async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
""" """
执行自动签到(始终执行) 执行自动签到(始终执行)

View File

@@ -2,6 +2,7 @@
余额查询操作抽象基类 余额查询操作抽象基类
""" """
import time
from abc import abstractmethod from abc import abstractmethod
from typing import Any from typing import Any
@@ -20,8 +21,11 @@ class BalanceAction(ProviderAction):
""" """
余额查询操作抽象基类 余额查询操作抽象基类
子类必须实现 _do_query_balance() 方法来处理特定平台的余额查询逻辑。 子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
子类可选实现 _do_checkin() 方法来在查询余额前执行签到 子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
显示 "Cookie 已失效" 而非 "认证失败"
""" """
action_type = ProviderActionType.QUERY_BALANCE action_type = ProviderActionType.QUERY_BALANCE
@@ -29,6 +33,9 @@ class BalanceAction(ProviderAction):
description = "查询账户余额信息" description = "查询账户余额信息"
default_cache_ttl = 86400 # 24 小时 default_cache_ttl = 86400 # 24 小时
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
_cookie_auth: bool = False
async def execute(self, client: httpx.AsyncClient) -> ActionResult: async def execute(self, client: httpx.AsyncClient) -> ActionResult:
""" """
执行余额查询(模板方法) 执行余额查询(模板方法)
@@ -67,10 +74,13 @@ class BalanceAction(ProviderAction):
return result return result
@abstractmethod
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult: async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
""" """
执行余额查询(子类必须实现) 执行余额查询
默认实现处理通用的请求/响应/错误处理流程。
子类只需实现 _parse_balance() 即可。
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
Args: Args:
client: 已认证的 HTTP 客户端 client: 已认证的 HTTP 客户端
@@ -78,8 +88,109 @@ class BalanceAction(ProviderAction):
Returns: Returns:
ActionResult其中 data 字段为 BalanceInfo 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 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: async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
""" """
执行签到(子类可选实现) 执行签到(子类可选实现)

View File

@@ -2,13 +2,10 @@
Cubence 余额查询操作 Cubence 余额查询操作
""" """
import time
from typing import Any from typing import Any
import httpx
from src.services.provider_ops.actions.balance import BalanceAction 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): class CubenceBalanceAction(BalanceAction):
@@ -24,84 +21,7 @@ class CubenceBalanceAction(BalanceAction):
display_name = "查询余额(含窗口限额)" display_name = "查询余额(含窗口限额)"
description = "查询账户余额和窗口限额信息" description = "查询账户余额和窗口限额信息"
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult: _cookie_auth = True
"""执行 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)
def _parse_balance(self, data: Any) -> BalanceInfo: def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析 Cubence 余额信息""" """解析 Cubence 余额信息"""

View File

@@ -2,15 +2,12 @@
NekoCode 余额查询操作 NekoCode 余额查询操作
""" """
import time
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import httpx
from src.core.logger import logger from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction 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): class NekoCodeBalanceAction(BalanceAction):
@@ -27,61 +24,7 @@ class NekoCodeBalanceAction(BalanceAction):
display_name = "查询余额" display_name = "查询余额"
description = "查询 NekoCode 账户余额和订阅信息" description = "查询 NekoCode 账户余额和订阅信息"
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult: _cookie_auth = True
"""执行余额查询"""
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)}",
)
def _parse_balance(self, data: Any) -> BalanceInfo: def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息""" """解析余额信息"""
@@ -160,23 +103,6 @@ class NekoCodeBalanceAction(BalanceAction):
extra=extra, 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 @classmethod
def get_config_schema(cls) -> dict[str, Any]: def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema""" """获取操作配置 schema"""

View File

@@ -2,14 +2,13 @@
New API 余额查询操作 New API 余额查询操作
""" """
import time
from typing import Any from typing import Any
import httpx import httpx
from src.core.logger import logger from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction 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): class NewApiBalanceAction(BalanceAction):
@@ -25,66 +24,6 @@ class NewApiBalanceAction(BalanceAction):
display_name = "查询余额" display_name = "查询余额"
description = "查询 New API 账户余额信息" 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( def _parse_balance(
self, self,
data: Any, data: Any,

View File

@@ -168,6 +168,12 @@ class YesCodeBalanceAction(BalanceAction):
display_name = "查询余额(含每周限额)" display_name = "查询余额(含每周限额)"
description = "查询账户余额和每周限额信息" 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: async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)""" """执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
import time import time

View File

@@ -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.generic_api import GenericApiArchitecture
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture 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.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
__all__ = [ __all__ = [
@@ -24,6 +23,5 @@ __all__ = [
"GenericApiArchitecture", "GenericApiArchitecture",
"NekoCodeArchitecture", "NekoCodeArchitecture",
"NewApiArchitecture", "NewApiArchitecture",
"OneApiArchitecture",
"YesCodeArchitecture", "YesCodeArchitecture",
] ]

View File

@@ -18,9 +18,9 @@ from src.services.provider_ops.actions import (
from src.services.provider_ops.architectures.base import ( from src.services.provider_ops.architectures.base import (
ProviderArchitecture, ProviderArchitecture,
ProviderConnector, ProviderConnector,
VerifyResult,
) )
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType 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 from src.utils.ssl_utils import get_ssl_context
# acw_sc__v2 算法常量 # acw_sc__v2 算法常量
@@ -93,31 +93,6 @@ def _compute_acw_sc_v2(arg1: str) -> str:
return result 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]: def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
""" """
从 session cookie 中解析用户 ID 和用户名 从 session cookie 中解析用户 ID 和用户名
@@ -138,7 +113,7 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
""" """
try: try:
# 先提取 session 值 # 先提取 session 值
session_cookie = _extract_session_from_cookie(cookie_input) session_cookie = extract_cookie_value(cookie_input, "session")
# 1. URL-safe base64 解码外层 # 1. URL-safe base64 解码外层
padding = 4 - len(session_cookie) % 4 padding = 4 - len(session_cookie) % 4
if padding != 4: if padding != 4:
@@ -292,7 +267,7 @@ class AnyrouterConnector(ProviderConnector):
return False return False
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值) # 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
self._session_cookie = _extract_session_from_cookie(session_cookie) self._session_cookie = extract_cookie_value(session_cookie, "session")
# 解析 user_id # 解析 user_id
self._user_id, _ = _parse_session_user_id(session_cookie) self._user_id, _ = _parse_session_user_id(session_cookie)
@@ -341,13 +316,36 @@ class AnyrouterConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://anyrouter.top",
},
"session_cookie": { "session_cookie": {
"type": "string", "type": "string",
"title": "Session Cookie", "title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值", "description": "从浏览器复制的 session Cookie 值",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": ["session_cookie"], "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") cookie_input = credentials.get("session_cookie")
if cookie_input: if cookie_input:
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值) # 提取 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}") cookies.append(f"session={session_value}")
# 从 session 解析 user_id 并添加 New-Api-User header # 从 session 解析 user_id 并添加 New-Api-User header
@@ -454,33 +452,8 @@ class AnyrouterArchitecture(ProviderArchitecture):
return headers return headers
def parse_verify_response( def _auth_fail_message(self, status_code: int) -> str:
self, """Cookie 认证的错误消息"""
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
"""解析 Anyrouter 验证响应"""
if status_code == 401: if status_code == 401:
return VerifyResult(success=False, message="Cookie 已失效,请重新配置") return "Cookie 已失效,请重新配置"
if status_code == 403: return "Cookie 已失效或无权限"
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,
)

View File

@@ -241,6 +241,9 @@ class ProviderArchitecture(ABC):
display_name: str = "" display_name: str = ""
description: str = "" description: str = ""
# 设为 True 时不在架构列表 API 中返回(内部使用的架构)
hidden: bool = False
# 支持的 Connector 类型列表(按优先级排序) # 支持的 Connector 类型列表(按优先级排序)
supported_connectors: list[type[ProviderConnector]] = [] supported_connectors: list[type[ProviderConnector]] = []
@@ -259,7 +262,7 @@ class ProviderArchitecture(ABC):
""" """
self.config = config or {} self.config = config or {}
# ==================== 认证验证相关方法(子类必须实现) ==================== # ==================== 认证验证相关方法 ====================
@abstractmethod @abstractmethod
def get_credentials_schema(self) -> dict[str, Any]: def get_credentials_schema(self) -> dict[str, Any]:
@@ -267,26 +270,9 @@ class ProviderArchitecture(ABC):
获取凭据字段定义JSON Schema 格式) 获取凭据字段定义JSON Schema 格式)
子类必须实现此方法定义需要的凭据字段。 子类必须实现此方法定义需要的凭据字段。
这个 schema 可用于:
1. 前端表单生成(如果需要动态渲染)
2. 凭据验证
3. 文档生成
Returns: Returns:
JSON Schema 格式的字段定义 JSON Schema 格式的字段定义
Example:
{
"type": "object",
"properties": {
"api_key": {
"type": "string",
"title": "API Key",
"description": "访问令牌",
},
},
"required": ["api_key"],
}
""" """
pass pass
@@ -322,7 +308,6 @@ class ProviderArchitecture(ABC):
""" """
pass pass
@abstractmethod
def parse_verify_response( def parse_verify_response(
self, self,
status_code: int, status_code: int,
@@ -331,7 +316,8 @@ class ProviderArchitecture(ABC):
""" """
解析认证验证响应 解析认证验证响应
子类必须实现此方法解析响应 默认实现处理通用的 {"success": bool, "data": {...}} 格式
子类可重写 _auth_fail_message() 和 _build_verify_result() 进行自定义。
Args: Args:
status_code: HTTP 状态码 status_code: HTTP 状态码
@@ -340,7 +326,61 @@ class ProviderArchitecture(ABC):
Returns: 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},
)
# ==================== 可选的钩子方法 ==================== # ==================== 可选的钩子方法 ====================

View File

@@ -16,31 +16,7 @@ from src.services.provider_ops.architectures.base import (
VerifyResult, VerifyResult,
) )
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
from src.services.provider_ops.utils import extract_cookie_value
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()
class CubenceConnector(ProviderConnector): class CubenceConnector(ProviderConnector):
@@ -66,7 +42,7 @@ class CubenceConnector(ProviderConnector):
return False return False
# 提取纯 token 值 # 提取纯 token 值
self._token_cookie = _extract_token_from_cookie(token_cookie) self._token_cookie = extract_cookie_value(token_cookie, "token")
self._set_connected() self._set_connected()
return True return True
@@ -93,13 +69,50 @@ class CubenceConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://cubence.com",
},
"token_cookie": { "token_cookie": {
"type": "string", "type": "string",
"title": "Token Cookie", "title": "Token Cookie",
"description": "从浏览器复制的 token Cookie 值JWT 格式)", "description": "从浏览器复制的 token Cookie 值JWT 格式)",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": ["token_cookie"], "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 # 添加 token Cookie
cookie_input = credentials.get("token_cookie") cookie_input = credentials.get("token_cookie")
if cookie_input: 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}" headers["Cookie"] = f"token={token_value}"
return headers return headers
def parse_verify_response( def _auth_fail_message(self, status_code: int) -> str:
self, """Cookie 认证的错误消息"""
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
"""解析 Cubence 验证响应"""
if status_code == 401: if status_code == 401:
return VerifyResult(success=False, message="Cookie 已失效,请重新配置") return "Cookie 已失效,请重新配置"
if status_code == 403: return "Cookie 已失效或无权限"
return VerifyResult(success=False, message="Cookie 已失效或无权限")
if status_code != 200:
return VerifyResult(success=False, message=f"验证失败HTTP {status_code}")
# Cubence 响应格式: {"success": true, "data": {...}} def _build_verify_result(
if not data.get("success"): self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
message = data.get("message", "验证失败") ) -> VerifyResult:
return VerifyResult(success=False, message=message) """Cubence 自定义字段提取user/balance/subscription_limits"""
user_data = data.get("data", {})
user_info = user_data.get("user", {}) user_info = user_data.get("user", {})
balance_info = user_data.get("balance", {}) balance_info = user_data.get("balance", {})
subscription_limits = user_data.get("subscription_limits", {}) subscription_limits = user_data.get("subscription_limits", {})

View File

@@ -50,7 +50,6 @@ from src.services.provider_ops.actions import (
from src.services.provider_ops.architectures.base import ( from src.services.provider_ops.architectures.base import (
ProviderArchitecture, ProviderArchitecture,
ProviderConnector, ProviderConnector,
VerifyResult,
) )
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
@@ -112,13 +111,35 @@ class GenericApiKeyConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"api_key": { "api_key": {
"type": "string", "type": "string",
"title": "API Key", "title": "API Key",
"description": "提供商的 API Key", "description": "提供商的 API Key",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": ["api_key"], "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" architecture_id = "generic_api"
display_name = "通用 API" display_name = "通用 API"
description = "可配置的通用 API 架构,适用于各种中转站" description = "可配置的通用 API 架构,适用于各种中转站"
hidden = True
supported_connectors: list[type[ProviderConnector]] = [ supported_connectors: list[type[ProviderConnector]] = [
GenericApiKeyConnector, GenericApiKeyConnector,
@@ -180,48 +202,3 @@ class GenericApiArchitecture(ProviderArchitecture):
headers[header_name] = api_key headers[header_name] = api_key
return headers 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",
)
},
)

View File

@@ -17,34 +17,10 @@ from src.services.provider_ops.architectures.base import (
VerifyResult, VerifyResult,
) )
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType 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 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): class NekoCodeConnector(ProviderConnector):
""" """
NekoCode 专用连接器 NekoCode 专用连接器
@@ -68,7 +44,7 @@ class NekoCodeConnector(ProviderConnector):
return False return False
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值) # 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
self._session_cookie = _extract_session_from_cookie(session_cookie) self._session_cookie = extract_cookie_value(session_cookie, "session")
self._set_connected() self._set_connected()
return True return True
@@ -95,13 +71,50 @@ class NekoCodeConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://nekocode.ai",
},
"session_cookie": { "session_cookie": {
"type": "string", "type": "string",
"title": "Session Cookie", "title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值", "description": "从浏览器复制的 session Cookie 值",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": ["session_cookie"], "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
cookie_input = credentials.get("session_cookie") cookie_input = credentials.get("session_cookie")
if cookie_input: 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}" headers["Cookie"] = f"session={session_value}"
# 构建 client 参数 # 构建 client 参数
@@ -218,31 +231,21 @@ class NekoCodeArchitecture(ProviderArchitecture):
cookie_input = credentials.get("session_cookie") cookie_input = credentials.get("session_cookie")
if cookie_input: if cookie_input:
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值) # 提取 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}" headers["Cookie"] = f"session={session_value}"
return headers return headers
def parse_verify_response( def _auth_fail_message(self, status_code: int) -> str:
self, """Cookie 认证的错误消息"""
status_code: int,
data: dict[str, Any],
) -> VerifyResult:
"""解析 NekoCode 验证响应(/api/user/self + _usage_summary"""
if status_code == 401: if status_code == 401:
return VerifyResult(success=False, message="Cookie 已失效,请重新配置") return "Cookie 已失效,请重新配置"
if status_code == 403: return "Cookie 已失效或无权限"
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", {})
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") balance = user_data.get("balance")
try: try:
@@ -252,11 +255,10 @@ class NekoCodeArchitecture(ProviderArchitecture):
# 从 prepare_verify_config 获取的 _usage_summary 数据(天卡信息) # 从 prepare_verify_config 获取的 _usage_summary 数据(天卡信息)
extra: dict[str, Any] = {} extra: dict[str, Any] = {}
usage_summary = data.get("_usage_summary", {}) usage_summary = (raw_data or {}).get("_usage_summary", {})
subscription = usage_summary.get("subscription", {}) subscription = usage_summary.get("subscription", {})
if subscription: if subscription:
# 转换字符串为数字
daily_limit = subscription.get("daily_quota_limit") daily_limit = subscription.get("daily_quota_limit")
daily_remaining = subscription.get("daily_remaining_quota") daily_remaining = subscription.get("daily_remaining_quota")
try: try:

View File

@@ -15,7 +15,6 @@ from src.services.provider_ops.actions import (
from src.services.provider_ops.architectures.base import ( from src.services.provider_ops.architectures.base import (
ProviderArchitecture, ProviderArchitecture,
ProviderConnector, ProviderConnector,
VerifyResult,
) )
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
@@ -90,10 +89,17 @@ class NewApiConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
},
"api_key": { "api_key": {
"type": "string", "type": "string",
"title": "访问令牌 (API Key)", "title": "访问令牌 (API Key)",
"description": "New API 的访问令牌,与 Cookie 二选一", "description": "New API 的访问令牌,与 Cookie 二选一",
"x-sensitive": True,
"x-input-type": "password",
}, },
"user_id": { "user_id": {
"type": "string", "type": "string",
@@ -104,9 +110,47 @@ class NewApiConnector(ProviderConnector):
"type": "string", "type": "string",
"title": "Cookie", "title": "Cookie",
"description": "用于 Cookie 认证,与访问令牌二选一", "description": "用于 Cookie 认证,与访问令牌二选一",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": [], "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 headers["Cookie"] = cookie
return headers 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",
)
},
)

View File

@@ -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",
)
},
)

View File

@@ -121,13 +121,52 @@ class YesCodeConnector(ProviderConnector):
return { return {
"type": "object", "type": "object",
"properties": { "properties": {
"base_url": {
"type": "string",
"title": "站点地址",
"description": "API 基础地址",
"x-default-value": "https://co.yes.vg",
},
"auth_cookie": { "auth_cookie": {
"type": "string", "type": "string",
"title": "Auth Cookie", "title": "Auth Cookie",
"description": "从浏览器复制的 Cookie包含 yescode_auth 和 yescode_csrf", "description": "从浏览器复制的 Cookie包含 yescode_auth 和 yescode_csrf",
"x-sensitive": True,
"x-input-type": "password",
}, },
}, },
"required": ["auth_cookie"], "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",
},
],
} }

View File

@@ -15,7 +15,6 @@ from src.services.provider_ops.architectures import (
GenericApiArchitecture, GenericApiArchitecture,
NekoCodeArchitecture, NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
OneApiArchitecture,
ProviderArchitecture, ProviderArchitecture,
YesCodeArchitecture, YesCodeArchitecture,
) )
@@ -58,7 +57,6 @@ class ArchitectureRegistry:
GenericApiArchitecture, GenericApiArchitecture,
NekoCodeArchitecture, NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
OneApiArchitecture,
YesCodeArchitecture, YesCodeArchitecture,
] ]
@@ -133,8 +131,8 @@ class ArchitectureRegistry:
return list(self._architectures.keys()) return list(self._architectures.keys())
def to_dict_list(self) -> list[dict]: def to_dict_list(self) -> list[dict]:
"""获取所有架构的字典表示(用于 API 响应)""" """获取所有架构的字典表示(用于 API 响应,隐藏 hidden 架构"""
return [arch.to_dict() for arch in self._architectures.values()] return [arch.to_dict() for arch in self._architectures.values() if not arch.hidden]
# 全局注册表实例 # 全局注册表实例

View 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()