mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强
- 新增三个 provider 架构:Anyrouter、Cubence、YesCode - 前端新增对应的认证配置模板 - 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示 - 架构基类新增 prepare_verify_config 异步预处理方法 - API 返回 ops_architecture_id 字段用于前端展示
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
.claude/
|
||||
.serena/
|
||||
.gemini*/
|
||||
.plans
|
||||
|
||||
### Python ###
|
||||
*.db
|
||||
|
||||
@@ -78,7 +78,7 @@ RUN printf '%s\n' \
|
||||
' return 404;' \
|
||||
' }' \
|
||||
'' \
|
||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||
' location ~ ^/(dashboard|admin|login|auth)(/|$) {' \
|
||||
' try_files $uri $uri/ /index.html;' \
|
||||
' }' \
|
||||
'' \
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 273 KiB |
@@ -311,6 +311,7 @@ export interface ProviderWithEndpointsSummary {
|
||||
api_formats: string[]
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
@@ -83,7 +83,11 @@ export interface BalanceInfo {
|
||||
total_available: number | null
|
||||
expires_at: string | null
|
||||
currency: string
|
||||
extra: Record<string, any>
|
||||
extra: Record<string, any> & {
|
||||
// Anyrouter 签到信息
|
||||
checkin_success?: boolean | null // true=成功, false=失败, null=已签到/跳过
|
||||
checkin_message?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 签到信息 */
|
||||
|
||||
84
frontend/src/features/providers/auth-templates/anyrouter.ts
Normal file
84
frontend/src/features/providers/auth-templates/anyrouter.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Anyrouter 认证模板
|
||||
*
|
||||
* 适用于 Anyrouter 中转站:
|
||||
* - 使用 Cookie 认证(session)
|
||||
* - 自动处理 acw_sc__v2 反爬 Cookie(后端处理)
|
||||
* - quota 单位是 1/500000 美元
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
|
||||
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 值',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
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: {},
|
||||
credentials: {
|
||||
session_cookie: formData.session_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
session_cookie: config?.connector?.credentials?.session_cookie || '',
|
||||
}
|
||||
},
|
||||
|
||||
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)}`
|
||||
},
|
||||
}
|
||||
157
frontend/src/features/providers/auth-templates/cubence.ts
Normal file
157
frontend/src/features/providers/auth-templates/cubence.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Cubence 认证模板
|
||||
*
|
||||
* 适用于 Cubence 中转站:
|
||||
* - 使用 Cookie 认证(token JWT)
|
||||
* - 余额单位直接是美元
|
||||
* - 支持窗口限额查询(5小时/每周)
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateFieldGroup, BalanceExtraItem } from './types'
|
||||
import type { SaveConfigRequest } from '@/api/providerOps'
|
||||
|
||||
/**
|
||||
* 格式化窗口限额显示(百分比格式)
|
||||
* 单位是美元(原始值除以 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 格式)',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
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: {},
|
||||
credentials: {
|
||||
token_cookie: formData.token_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
token_cookie: config?.connector?.credentials?.token_cookie || '',
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -22,12 +22,15 @@
|
||||
*/
|
||||
|
||||
import type { AuthTemplate, AuthTemplateRegistry } from './types'
|
||||
import { anyrouterTemplate } from './anyrouter'
|
||||
import { cubenceTemplate } from './cubence'
|
||||
import { newApiTemplate } from './new-api'
|
||||
import { yescodeTemplate } from './yescode'
|
||||
|
||||
// ==================== 模板注册 ====================
|
||||
// 在这里添加新模板
|
||||
|
||||
const templates: AuthTemplate[] = [newApiTemplate]
|
||||
const templates: AuthTemplate[] = [newApiTemplate, anyrouterTemplate, cubenceTemplate, yescodeTemplate]
|
||||
|
||||
// ==================== 注册表实现 ====================
|
||||
|
||||
@@ -63,4 +66,7 @@ export const authTemplateRegistry: AuthTemplateRegistry = {
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export * from './types'
|
||||
export { anyrouterTemplate } from './anyrouter'
|
||||
export { cubenceTemplate } from './cubence'
|
||||
export { newApiTemplate } from './new-api'
|
||||
export { yescodeTemplate } from './yescode'
|
||||
|
||||
@@ -103,6 +103,29 @@ export interface AuthTemplate {
|
||||
* @param quota quota 值
|
||||
*/
|
||||
formatQuota?(quota: number): string
|
||||
|
||||
/**
|
||||
* 格式化余额 extra 信息(如窗口限额等)
|
||||
* 返回一个数组,每个元素包含 label 和 value
|
||||
* @param extra 余额 extra 字段
|
||||
*/
|
||||
formatBalanceExtra?(extra: Record<string, any>): BalanceExtraItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 余额附加信息项
|
||||
*/
|
||||
export interface BalanceExtraItem {
|
||||
/** 显示标签 */
|
||||
label: string
|
||||
/** 显示值 */
|
||||
value: string
|
||||
/** 百分比数值 (0-100),用于进度条显示 */
|
||||
percent?: number
|
||||
/** 重置时间戳(Unix 秒),用于倒计时显示 */
|
||||
resetsAt?: number
|
||||
/** 可选的提示文本 */
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
135
frontend/src/features/providers/auth-templates/yescode.ts
Normal file
135
frontend/src/features/providers/auth-templates/yescode.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/**
|
||||
* 格式化限额显示(百分比格式)
|
||||
*/
|
||||
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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
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: {},
|
||||
credentials: {
|
||||
auth_cookie: formData.auth_cookie,
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
schedule: {},
|
||||
}
|
||||
},
|
||||
|
||||
parseConfig(config: any): Record<string, any> {
|
||||
return {
|
||||
base_url: config?.base_url || '',
|
||||
auth_cookie: config?.connector?.credentials?.auth_cookie || '',
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
@@ -175,7 +175,7 @@ import {
|
||||
} from '../auth-templates'
|
||||
|
||||
// 敏感字段列表(用于验证和加载配置时的特殊处理)
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'cookie_string', 'cookies'] as const
|
||||
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookies'] as const
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -427,11 +427,11 @@ function loadFromConfig(config: any) {
|
||||
|
||||
hasExistingConfig.value = true
|
||||
|
||||
// 目前只支持 new_api 模板
|
||||
selectedTemplateId.value = 'new_api'
|
||||
|
||||
// 使用模板解析配置
|
||||
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api
|
||||
const architectureId = config.architecture_id || 'new_api'
|
||||
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api'
|
||||
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
||||
|
||||
if (template) {
|
||||
const parsedData = template.parseConfig(config)
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
|
||||
提供商信息
|
||||
</TableHead>
|
||||
<TableHead class="w-[140px] h-11 font-medium text-foreground/80 text-center">
|
||||
<TableHead class="w-[100px] h-11 font-medium text-foreground/80">
|
||||
余额监控
|
||||
</TableHead>
|
||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
|
||||
@@ -142,15 +142,73 @@
|
||||
</a>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell class="py-3.5 text-center">
|
||||
<TableCell class="py-3.5">
|
||||
<!-- 显示从上游 API 查询的余额 -->
|
||||
<div
|
||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="text-xs"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<span class="font-semibold text-foreground/90">
|
||||
<!-- 余额文字 -->
|
||||
<span class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums">
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
<!-- 窗口限额 + 签到状态 -->
|
||||
<div
|
||||
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id)"
|
||||
class="text-muted-foreground/70 space-y-0.5"
|
||||
>
|
||||
<!-- 限额(进度条 + 倒计时,每行一个) -->
|
||||
<template
|
||||
v-for="item in getProviderBalanceExtra(provider.id, provider.ops_architecture_id)"
|
||||
:key="item.label"
|
||||
>
|
||||
<div
|
||||
:title="item.tooltip"
|
||||
:class="['flex items-center gap-1', item.tooltip ? 'cursor-help' : '']"
|
||||
>
|
||||
<span class="text-[10px] text-muted-foreground/60 w-4">{{ item.label }}</span>
|
||||
<div class="w-12 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="[
|
||||
item.percent !== undefined && item.percent >= 50 ? 'bg-green-500' :
|
||||
item.percent !== undefined && item.percent >= 20 ? 'bg-amber-500' : 'bg-red-500'
|
||||
]"
|
||||
:style="{ width: `${item.percent ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-muted-foreground/50 w-7 text-right tabular-nums">{{ item.value }}</span>
|
||||
<span
|
||||
v-if="item.resetsAt"
|
||||
class="text-[10px] text-muted-foreground/40 w-14 text-right tabular-nums"
|
||||
>{{ formatResetCountdown(item.resetsAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 签到状态 -->
|
||||
<div
|
||||
v-if="getProviderCheckin(provider.id)"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
v-if="getProviderCheckin(provider.id)?.success === true"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id)?.success === false"
|
||||
class="text-[10px] text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<div
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-xs text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</div>
|
||||
<!-- 显示本地配置的月度配额 -->
|
||||
<div
|
||||
@@ -371,6 +429,25 @@
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
||||
<!-- 签到状态显示 -->
|
||||
<span
|
||||
v-if="getProviderCheckin(provider.id)?.success === true"
|
||||
class="ml-1 text-muted-foreground"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>已签到</span>
|
||||
<span
|
||||
v-else-if="getProviderCheckin(provider.id)?.success === false"
|
||||
class="ml-1 text-destructive/70"
|
||||
:title="getProviderCheckin(provider.id)?.message"
|
||||
>签到失败</span>
|
||||
</span>
|
||||
<!-- 余额查询失败时显示错误 -->
|
||||
<span
|
||||
v-else-if="provider.ops_configured && getProviderBalanceError(provider.id)"
|
||||
class="text-destructive/80"
|
||||
:title="getProviderBalanceError(provider.id)?.message"
|
||||
>
|
||||
{{ getProviderBalanceError(provider.id)?.message }}
|
||||
</span>
|
||||
<!-- 本地配额 -->
|
||||
<span
|
||||
@@ -459,7 +536,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
@@ -496,6 +573,7 @@ import {
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
|
||||
import { formatBillingType } from '@/utils/format'
|
||||
import { authTemplateRegistry, type BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { confirmDanger } = useConfirm()
|
||||
@@ -658,6 +736,46 @@ function getProviderBalance(providerId: string): { available: number | null; cur
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 余额查询的错误状态
|
||||
function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result) {
|
||||
return null
|
||||
}
|
||||
// 认证失败或过期
|
||||
if (result.status === 'auth_failed' || result.status === 'auth_expired') {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message || '认证失败'
|
||||
}
|
||||
}
|
||||
// 其他错误
|
||||
if (result.status !== 'success') {
|
||||
return {
|
||||
status: result.status,
|
||||
message: result.message || '查询失败'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取 provider 的签到信息(从 extra 字段)
|
||||
function getProviderCheckin(providerId: string): { success: boolean | null; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || result.status !== 'success' || !result.data) {
|
||||
return null
|
||||
}
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra || extra.checkin_success === undefined) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
success: extra.checkin_success,
|
||||
message: extra.checkin_message || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化余额显示
|
||||
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
|
||||
if (!balance || balance.available == null) {
|
||||
@@ -667,6 +785,48 @@ function formatBalanceDisplay(balance: { available: number | null; currency: str
|
||||
return `${symbol}${balance.available.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 格式化重置倒计时(从 Unix 时间戳)
|
||||
function formatResetCountdown(resetsAt: number): string {
|
||||
// 依赖 tickCounter 触发响应式更新
|
||||
void tickCounter.value
|
||||
|
||||
const now = Date.now() / 1000
|
||||
const diff = resetsAt - now
|
||||
|
||||
if (diff <= 0) return '即将重置'
|
||||
|
||||
const totalHours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
const seconds = Math.floor(diff % 60)
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
|
||||
if (totalHours > 0) {
|
||||
return `${totalHours}:${pad(minutes)}:${pad(seconds)}`
|
||||
}
|
||||
return `${minutes}:${pad(seconds)}`
|
||||
}
|
||||
|
||||
// 获取 provider 余额的额外信息(如窗口限额)
|
||||
function getProviderBalanceExtra(providerId: string, architectureId?: string): BalanceExtraItem[] {
|
||||
if (!architectureId) return []
|
||||
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || result.status !== 'success' || !result.data) {
|
||||
return []
|
||||
}
|
||||
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra) return []
|
||||
|
||||
// 获取对应的模板
|
||||
const template = authTemplateRegistry.get(architectureId)
|
||||
if (!template?.formatBalanceExtra) return []
|
||||
|
||||
return template.formatBalanceExtra(extra)
|
||||
}
|
||||
|
||||
|
||||
// 格式化官网显示
|
||||
function formatWebsiteDisplay(url: string): string {
|
||||
@@ -844,8 +1004,22 @@ async function toggleProviderStatus(provider: ProviderWithEndpointsSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
// 用于触发倒计时更新的响应式计数器
|
||||
const tickCounter = ref(0)
|
||||
let tickInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadPriorityMode()
|
||||
// 每秒更新一次倒计时
|
||||
tickInterval = setInterval(() => {
|
||||
tickCounter.value++
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (tickInterval) {
|
||||
clearInterval(tickInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -290,7 +290,9 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
|
||||
]
|
||||
|
||||
# 检查是否配置了 Provider Ops(余额监控等)
|
||||
ops_configured = bool((provider.config or {}).get("provider_ops"))
|
||||
provider_ops_config = (provider.config or {}).get("provider_ops")
|
||||
ops_configured = bool(provider_ops_config)
|
||||
ops_architecture_id = provider_ops_config.get("architecture_id") if provider_ops_config else None
|
||||
|
||||
return ProviderWithEndpointsSummary(
|
||||
id=provider.id,
|
||||
@@ -318,6 +320,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
|
||||
api_formats=api_formats,
|
||||
endpoint_health_details=endpoint_health_details,
|
||||
ops_configured=ops_configured,
|
||||
ops_architecture_id=ops_architecture_id,
|
||||
created_at=provider.created_at,
|
||||
updated_at=provider.updated_at,
|
||||
)
|
||||
|
||||
@@ -641,6 +641,9 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
|
||||
# Provider Ops 配置状态
|
||||
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
|
||||
ops_architecture_id: Optional[str] = Field(
|
||||
default=None, description="扩展操作使用的架构 ID(如 cubence, anyrouter)"
|
||||
)
|
||||
|
||||
# 时间戳
|
||||
created_at: datetime
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
Provider 操作模块
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.actions.checkin import CheckinAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
|
||||
|
||||
__all__ = [
|
||||
"ProviderAction",
|
||||
"BalanceAction",
|
||||
"CheckinAction",
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
|
||||
107
src/services/provider_ops/actions/anyrouter_balance.py
Normal file
107
src/services/provider_ops/actions/anyrouter_balance.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Anyrouter 余额查询操作(含自动签到)
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus
|
||||
|
||||
|
||||
class AnyrouterBalanceAction(BalanceAction):
|
||||
"""
|
||||
Anyrouter 专用余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额前自动触发签到
|
||||
- 签到结果附加到余额信息的 extra 字段
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含自动签到)"
|
||||
description = "查询账户余额,同时自动签到"
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应(Anyrouter 专用)"""
|
||||
status_code = response.status_code
|
||||
|
||||
# Anyrouter 使用 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)
|
||||
|
||||
async def execute(self, client) -> ActionResult:
|
||||
"""执行余额查询(含自动签到)"""
|
||||
# 先尝试签到
|
||||
checkin_success, checkin_message = await self._auto_checkin(client)
|
||||
|
||||
# 执行余额查询
|
||||
result = await super().execute(client)
|
||||
|
||||
# 将签到结果附加到 extra 字段
|
||||
if result.data and hasattr(result.data, "extra"):
|
||||
if result.data.extra is None:
|
||||
result.data.extra = {}
|
||||
result.data.extra["checkin_success"] = checkin_success
|
||||
result.data.extra["checkin_message"] = checkin_message
|
||||
|
||||
return result
|
||||
|
||||
async def _auto_checkin(self, client) -> Tuple[Optional[bool], str]:
|
||||
"""
|
||||
自动签到
|
||||
|
||||
Returns:
|
||||
(success, message) 元组:
|
||||
- success: True=签到成功, False=签到失败, None=已签到/跳过
|
||||
- message: 签到消息
|
||||
"""
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
data = response.json()
|
||||
success = data.get("success", False)
|
||||
message = data.get("message", "")
|
||||
|
||||
if success:
|
||||
logger.debug(f"Anyrouter 自动签到成功: {message}")
|
||||
return True, message or "签到成功"
|
||||
else:
|
||||
# 检查是否是"已签到"
|
||||
is_already = (
|
||||
any(ind in message for ind in ["已签到", "已签", "今日已"])
|
||||
or "already" in message.lower()
|
||||
)
|
||||
if is_already:
|
||||
logger.debug(f"Anyrouter 今日已签到: {message}")
|
||||
return None, message or "今日已签到"
|
||||
else:
|
||||
logger.debug(f"Anyrouter 签到失败: {message}")
|
||||
return False, message or "签到失败"
|
||||
except Exception as e:
|
||||
logger.debug(f"Anyrouter 签到响应解析失败: {e}")
|
||||
return False, "响应解析失败"
|
||||
else:
|
||||
logger.debug(f"Anyrouter 签到请求失败: HTTP {response.status_code}")
|
||||
return False, f"HTTP {response.status_code}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Anyrouter 自动签到异常: {e}")
|
||||
return False, str(e)
|
||||
95
src/services/provider_ops/actions/cubence_balance.py
Normal file
95
src/services/provider_ops/actions/cubence_balance.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Cubence 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
class CubenceBalanceAction(BalanceAction):
|
||||
"""
|
||||
Cubence 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额查询(5小时/每周)
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含窗口限额)"
|
||||
description = "查询账户余额和窗口限额信息"
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = 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, mapping: Dict[str, str]) -> BalanceInfo:
|
||||
"""解析 Cubence 余额信息(覆盖基类方法)"""
|
||||
# Cubence 响应格式:data.balance 和 data.subscription_limits
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
balance_data = response_data.get("balance", {})
|
||||
subscription_limits = response_data.get("subscription_limits", {})
|
||||
|
||||
# 余额信息(单位直接是美元)
|
||||
total_available = balance_data.get("total_balance_dollar")
|
||||
normal_balance = balance_data.get("normal_balance_dollar")
|
||||
subscription_balance = balance_data.get("subscription_balance_dollar")
|
||||
charity_balance = balance_data.get("charity_balance_dollar")
|
||||
|
||||
# 窗口限额信息
|
||||
extra: Dict[str, Any] = {}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
# 余额组成
|
||||
if normal_balance is not None:
|
||||
extra["normal_balance"] = normal_balance
|
||||
if subscription_balance is not None:
|
||||
extra["subscription_balance"] = subscription_balance
|
||||
if charity_balance is not None:
|
||||
extra["charity_balance"] = charity_balance
|
||||
|
||||
return BalanceInfo(
|
||||
total_granted=None, # Cubence 不提供总额度
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
226
src/services/provider_ops/actions/yescode_balance.py
Normal file
226
src/services/provider_ops/actions/yescode_balance.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
YesCode 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
async def fetch_yescode_combined_data(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取 YesCode 合并数据(balance + profile)
|
||||
|
||||
使用传入的 client 并发调用两个接口:
|
||||
- /api/v1/user/balance: 准确的 weekly_spent_balance
|
||||
- /api/v1/auth/profile: 用户信息、重置时间
|
||||
|
||||
Args:
|
||||
client: 已配置好认证的 HTTP 客户端
|
||||
base_url: API 基础地址
|
||||
|
||||
Returns:
|
||||
合并后的数据字典
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# 并发调用两个接口
|
||||
balance_task = client.get(f"{base_url}/api/v1/user/balance")
|
||||
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
|
||||
|
||||
balance_resp, profile_resp = await asyncio.gather(
|
||||
balance_task, profile_task, return_exceptions=True
|
||||
)
|
||||
|
||||
# 解析 balance 接口
|
||||
balance_data: Dict[str, Any] = {}
|
||||
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
|
||||
try:
|
||||
balance_data = balance_resp.json()
|
||||
result["_balance_data"] = balance_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析 profile 接口
|
||||
profile_data: Dict[str, Any] = {}
|
||||
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
|
||||
try:
|
||||
profile_data = profile_resp.json()
|
||||
result["_profile_data"] = profile_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 合并数据:balance 接口优先(更准确的余额数据)
|
||||
result["pay_as_you_go_balance"] = balance_data.get(
|
||||
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
|
||||
)
|
||||
result["subscription_balance"] = balance_data.get(
|
||||
"subscription_balance", profile_data.get("subscription_balance", 0)
|
||||
)
|
||||
result["weekly_limit"] = balance_data.get("weekly_limit") or (
|
||||
profile_data.get("subscription_plan") or {}
|
||||
).get("weekly_limit")
|
||||
result["weekly_spent_balance"] = balance_data.get(
|
||||
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
|
||||
)
|
||||
|
||||
# 用户信息(仅 profile 有)
|
||||
result["username"] = profile_data.get("username")
|
||||
result["email"] = profile_data.get("email")
|
||||
|
||||
# 重置时间(仅 profile 有)
|
||||
result["last_week_reset"] = profile_data.get("last_week_reset")
|
||||
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
|
||||
|
||||
# subscription_plan(仅 profile 有)
|
||||
result["subscription_plan"] = profile_data.get("subscription_plan")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_yescode_balance_extra(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
解析 YesCode 余额额外信息
|
||||
|
||||
Args:
|
||||
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
|
||||
|
||||
Returns:
|
||||
统一格式的 extra 字典
|
||||
"""
|
||||
extra: Dict[str, Any] = {}
|
||||
|
||||
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
|
||||
subscription = data.get("subscription_balance", 0)
|
||||
|
||||
extra["pay_as_you_go_balance"] = pay_as_you_go
|
||||
|
||||
# 每日额度上限
|
||||
plan = data.get("subscription_plan") or {}
|
||||
daily_balance = plan.get("daily_balance", subscription)
|
||||
|
||||
# 周限额
|
||||
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
|
||||
weekly_spent = data.get("weekly_spent_balance", 0)
|
||||
|
||||
# 映射为统一字段
|
||||
extra["daily_limit"] = daily_balance
|
||||
if weekly_limit is not None:
|
||||
extra["weekly_limit"] = weekly_limit
|
||||
extra["weekly_spent"] = weekly_spent
|
||||
|
||||
# 计算重置时间
|
||||
last_week_reset = data.get("last_week_reset")
|
||||
if last_week_reset:
|
||||
try:
|
||||
if isinstance(last_week_reset, str):
|
||||
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
|
||||
next_reset = reset_dt + timedelta(days=7)
|
||||
extra["weekly_resets_at"] = int(next_reset.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_daily_add = data.get("last_daily_balance_add")
|
||||
if last_daily_add:
|
||||
try:
|
||||
if isinstance(last_daily_add, str):
|
||||
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
|
||||
next_daily = add_dt + timedelta(days=1)
|
||||
extra["daily_resets_at"] = int(next_daily.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算实际可用余额
|
||||
if weekly_limit is not None:
|
||||
weekly_remaining = max(0, weekly_limit - weekly_spent)
|
||||
subscription_available = min(subscription, weekly_remaining)
|
||||
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
|
||||
else:
|
||||
subscription_available = subscription
|
||||
extra["daily_spent"] = max(0, daily_balance - subscription)
|
||||
|
||||
extra["_subscription_available"] = subscription_available
|
||||
extra["_total_available"] = pay_as_you_go + subscription_available
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
class YesCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
YesCode 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
- 同时调用 balance 和 profile 接口获取完整数据
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含每周限额)"
|
||||
description = "查询账户余额和每周限额信息"
|
||||
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(复用 client 调用两个接口获取完整数据)"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
# 复用传入的 client 获取合并数据
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查是否至少有一个接口成功
|
||||
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED,
|
||||
"Cookie 已失效,请重新配置",
|
||||
)
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
balance = BalanceInfo(
|
||||
total_granted=None,
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=combined_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)}",
|
||||
)
|
||||
@@ -7,15 +7,21 @@ from src.services.provider_ops.architectures.base import (
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
|
||||
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.new_api import NewApiArchitecture
|
||||
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
|
||||
__all__ = [
|
||||
"ProviderArchitecture",
|
||||
"ProviderConnector",
|
||||
"VerifyResult",
|
||||
"AnyrouterArchitecture",
|
||||
"CubenceArchitecture",
|
||||
"GenericApiArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"OneApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
]
|
||||
|
||||
444
src/services/provider_ops/architectures/anyrouter.py
Normal file
444
src/services/provider_ops/architectures/anyrouter.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Anyrouter 架构
|
||||
|
||||
针对 Anyrouter 中转站的预设配置,自动处理 acw_sc__v2 反爬 Cookie。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions import AnyrouterBalanceAction, ProviderAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
# acw_sc__v2 算法常量
|
||||
_XOR_KEY = "3000176000856006061501533003690027800375"
|
||||
_UNSBOX_TABLE = [
|
||||
0xF,
|
||||
0x23,
|
||||
0x1D,
|
||||
0x18,
|
||||
0x21,
|
||||
0x10,
|
||||
0x1,
|
||||
0x26,
|
||||
0xA,
|
||||
0x9,
|
||||
0x13,
|
||||
0x1F,
|
||||
0x28,
|
||||
0x1B,
|
||||
0x16,
|
||||
0x17,
|
||||
0x19,
|
||||
0xD,
|
||||
0x6,
|
||||
0xB,
|
||||
0x27,
|
||||
0x12,
|
||||
0x14,
|
||||
0x8,
|
||||
0xE,
|
||||
0x15,
|
||||
0x20,
|
||||
0x1A,
|
||||
0x2,
|
||||
0x1E,
|
||||
0x7,
|
||||
0x4,
|
||||
0x11,
|
||||
0x5,
|
||||
0x3,
|
||||
0x1C,
|
||||
0x22,
|
||||
0x25,
|
||||
0xC,
|
||||
0x24,
|
||||
]
|
||||
|
||||
|
||||
def _compute_acw_sc_v2(arg1: str) -> str:
|
||||
"""
|
||||
计算 acw_sc__v2 Cookie 值
|
||||
|
||||
Args:
|
||||
arg1: 从 HTML 中提取的 40 位十六进制字符串
|
||||
|
||||
Returns:
|
||||
计算后的 Cookie 值
|
||||
"""
|
||||
# Step 1: unsbox - 根据置换表重排字符
|
||||
unsboxed = "".join(arg1[i - 1] for i in _UNSBOX_TABLE)
|
||||
|
||||
# Step 2: hexXor - 与密钥逐字节异或
|
||||
result = ""
|
||||
for i in range(0, 40, 2):
|
||||
a = int(unsboxed[i : i + 2], 16)
|
||||
b = int(_XOR_KEY[i : i + 2], 16)
|
||||
xored = format(a ^ b, "02x")
|
||||
result += xored
|
||||
|
||||
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[Optional[str], Optional[str]]:
|
||||
"""
|
||||
从 session cookie 中解析用户 ID 和用户名
|
||||
|
||||
Anyrouter 的 session cookie 结构:
|
||||
base64(timestamp|gob_base64|signature)
|
||||
|
||||
gob 数据中包含:
|
||||
- id: 内部数字 ID
|
||||
- username: 用户名 (如 linuxdo_129083)
|
||||
- role, status, group 等
|
||||
|
||||
Args:
|
||||
cookie_input: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
(user_id, username) 元组,解析失败则返回 (None, None)
|
||||
"""
|
||||
try:
|
||||
# 先提取 session 值
|
||||
session_cookie = _extract_session_from_cookie(cookie_input)
|
||||
# 1. URL-safe base64 解码外层
|
||||
padding = 4 - len(session_cookie) % 4
|
||||
if padding != 4:
|
||||
session_cookie += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(session_cookie)
|
||||
text = decoded.decode("utf-8", errors="replace")
|
||||
|
||||
# 2. 分割: timestamp|gob_base64|signature
|
||||
parts = text.split("|")
|
||||
if len(parts) < 2:
|
||||
return None, None
|
||||
|
||||
# 3. 解码 gob 数据 (第二层 base64)
|
||||
gob_b64 = parts[1]
|
||||
padding2 = 4 - len(gob_b64) % 4
|
||||
if padding2 != 4:
|
||||
gob_b64 += "=" * padding2
|
||||
|
||||
gob_data = base64.urlsafe_b64decode(gob_b64)
|
||||
|
||||
# 4. 从 gob 数据中提取用户名
|
||||
gob_text = gob_data.decode("utf-8", errors="ignore")
|
||||
|
||||
# 查找 linuxdo_xxx 模式 (LinuxDo OAuth)
|
||||
linuxdo_match = re.search(r"linuxdo_(\d+)", gob_text)
|
||||
if linuxdo_match:
|
||||
user_id = linuxdo_match.group(1)
|
||||
username = linuxdo_match.group(0)
|
||||
return user_id, username
|
||||
|
||||
# 查找其他 OAuth 格式 (github_xxx, google_xxx 等)
|
||||
oauth_match = re.search(r"(github|google|discord|twitter)_(\d+)", gob_text, re.IGNORECASE)
|
||||
if oauth_match:
|
||||
user_id = oauth_match.group(2)
|
||||
username = oauth_match.group(0)
|
||||
return user_id, username
|
||||
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.debug(f"解析 Anyrouter session cookie 失败: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
async def _get_acw_cookie(base_url: str, timeout: float = 10) -> Optional[str]:
|
||||
"""
|
||||
获取 acw_sc__v2 Cookie
|
||||
|
||||
首先请求目标 URL,如果返回包含 arg1 的反爬页面,则计算 Cookie 值。
|
||||
|
||||
Args:
|
||||
base_url: 目标站点 URL
|
||||
timeout: 请求超时时间
|
||||
|
||||
Returns:
|
||||
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(
|
||||
base_url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# 尝试从响应中提取 arg1
|
||||
match = re.search(r"var\s+arg1\s*=\s*'([0-9a-fA-F]{40})'", resp.text)
|
||||
if not match:
|
||||
# 没有反爬页面,不需要 Cookie
|
||||
return None
|
||||
|
||||
cookie_value = _compute_acw_sc_v2(match.group(1))
|
||||
return f"acw_sc__v2={cookie_value}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 acw_sc__v2 Cookie 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class AnyrouterConnector(ProviderConnector):
|
||||
"""
|
||||
Anyrouter 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动补充 acw_sc__v2 反爬 Cookie
|
||||
- 自动解析 user_id 用于 New-Api-User header
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Anyrouter Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._session_cookie: Optional[str] = None
|
||||
self._acw_cookie: Optional[str] = None
|
||||
self._user_id: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
session_cookie = credentials.get("session_cookie")
|
||||
if not session_cookie:
|
||||
self._set_error("Session Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = _extract_session_from_cookie(session_cookie)
|
||||
|
||||
# 解析 user_id
|
||||
self._user_id, _ = _parse_session_user_id(session_cookie)
|
||||
|
||||
# 尝试获取反爬 Cookie
|
||||
self._acw_cookie = await _get_acw_cookie(self.base_url)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._session_cookie = None
|
||||
self._acw_cookie = None
|
||||
self._user_id = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._session_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
if self._acw_cookie:
|
||||
cookies.append(self._acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
if self._session_cookie:
|
||||
cookies.append(f"session={self._session_cookie}")
|
||||
|
||||
if cookies:
|
||||
request.headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
# 添加 New-Api-User header
|
||||
if self._user_id:
|
||||
request.headers["New-Api-User"] = self._user_id
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class AnyrouterArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Anyrouter 架构预设
|
||||
|
||||
针对 Anyrouter 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动处理 acw_sc__v2 反爬 Cookie
|
||||
- 验证端点: /api/user/self
|
||||
- quota 单位是 1/500000 美元
|
||||
"""
|
||||
|
||||
architecture_id = "anyrouter"
|
||||
display_name = "Anyrouter"
|
||||
description = "Anyrouter 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
AnyrouterConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
AnyrouterBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/user/self",
|
||||
"method": "GET",
|
||||
"quota_divisor": 500000, # 与 New API 相同
|
||||
"checkin_endpoint": "/api/user/sign_in", # 自动签到端点
|
||||
"response_mapping": {
|
||||
"total_granted": "data.quota",
|
||||
"total_used": "data.used_quota",
|
||||
"total_available": "data.quota",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""Anyrouter 使用 session_cookie 认证"""
|
||||
return AnyrouterConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
验证前获取 acw_sc__v2 Cookie
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
包含 acw_cookie 的配置
|
||||
"""
|
||||
acw_cookie = await _get_acw_cookie(base_url)
|
||||
if acw_cookie:
|
||||
return {"acw_cookie": acw_cookie}
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 Anyrouter 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
同时添加 New-Api-User header。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
acw_cookie = config.get("acw_cookie")
|
||||
if acw_cookie:
|
||||
cookies.append(acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = _extract_session_from_cookie(cookie_input)
|
||||
cookies.append(f"session={session_value}")
|
||||
|
||||
# 从 session 解析 user_id 并添加 New-Api-User header
|
||||
user_id, _ = _parse_session_user_id(cookie_input)
|
||||
if user_id:
|
||||
headers["New-Api-User"] = user_id
|
||||
|
||||
if cookies:
|
||||
headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Anyrouter 验证响应"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# Anyrouter 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username"),
|
||||
display_name=user_data.get("display_name") or user_data.get("username"),
|
||||
email=user_data.get("email"),
|
||||
quota=user_data.get("quota"),
|
||||
used_quota=user_data.get("used_quota"),
|
||||
request_count=user_data.get("request_count"),
|
||||
extra=None,
|
||||
)
|
||||
@@ -310,6 +310,28 @@ class ProviderArchitecture(ABC):
|
||||
"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
验证前的异步预处理
|
||||
|
||||
子类可重写以执行异步操作(如获取动态 Cookie)。
|
||||
返回的配置会传递给 build_verify_headers。
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
处理后的配置(会与原 config 合并)
|
||||
"""
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
|
||||
224
src/services/provider_ops/architectures/cubence.py
Normal file
224
src/services/provider_ops/architectures/cubence.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Cubence 架构
|
||||
|
||||
针对 Cubence 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
def _extract_token_from_cookie(cookie_string: str) -> str:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 token 值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "token=xxx; other=yyy; ..."
|
||||
2. 仅 token 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 token 值
|
||||
|
||||
Returns:
|
||||
token cookie 的值
|
||||
"""
|
||||
# 如果包含 "token=",说明是完整 Cookie 字符串
|
||||
if "token=" in cookie_string:
|
||||
# 解析 Cookie 字符串
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith("token="):
|
||||
return part[6:] # 去掉 "token=" 前缀
|
||||
# 否则认为直接是 token 值
|
||||
return cookie_string.strip()
|
||||
|
||||
|
||||
class CubenceConnector(ProviderConnector):
|
||||
"""
|
||||
Cubence 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Cubence Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._token_cookie: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
token_cookie = credentials.get("token_cookie")
|
||||
if not token_cookie:
|
||||
self._set_error("Token Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 token 值
|
||||
self._token_cookie = _extract_token_from_cookie(token_cookie)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._token_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._token_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._token_cookie:
|
||||
request.headers["Cookie"] = f"token={self._token_cookie}"
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token_cookie": {
|
||||
"type": "string",
|
||||
"title": "Token Cookie",
|
||||
"description": "从浏览器复制的 token Cookie 值(JWT 格式)",
|
||||
},
|
||||
},
|
||||
"required": ["token_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class CubenceArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Cubence 架构预设
|
||||
|
||||
针对 Cubence 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
- 验证端点: /api/v1/dashboard/overview
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额(5小时/每周)
|
||||
"""
|
||||
|
||||
architecture_id = "cubence"
|
||||
display_name = "Cubence"
|
||||
description = "Cubence 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
CubenceConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
CubenceBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/dashboard/overview",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "data.balance.total_balance_dollar",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""Cubence 使用 token_cookie 认证"""
|
||||
return CubenceConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/v1/dashboard/overview"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 Cubence 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
# 添加 token Cookie
|
||||
cookie_input = credentials.get("token_cookie")
|
||||
if cookie_input:
|
||||
token_value = _extract_token_from_cookie(cookie_input)
|
||||
headers["Cookie"] = f"token={token_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 Cubence 验证响应"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# Cubence 响应格式: {"success": true, "data": {...}}
|
||||
if not data.get("success"):
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
user_info = user_data.get("user", {})
|
||||
balance_info = user_data.get("balance", {})
|
||||
subscription_limits = user_data.get("subscription_limits", {})
|
||||
|
||||
# 构建 extra 信息,包含窗口限额
|
||||
extra: Dict[str, Any] = {
|
||||
"role": user_info.get("role"),
|
||||
"invite_code": user_info.get("invite_code"),
|
||||
}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_info.get("username"),
|
||||
display_name=user_info.get("username"),
|
||||
quota=balance_info.get("total_balance_dollar"),
|
||||
extra=extra,
|
||||
)
|
||||
289
src/services/provider_ops/architectures/yescode.py
Normal file
289
src/services/provider_ops/architectures/yescode.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
YesCode 架构
|
||||
|
||||
针对 YesCode 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.yescode_balance import (
|
||||
YesCodeBalanceAction,
|
||||
fetch_yescode_combined_data,
|
||||
parse_yescode_balance_extra,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
def _extract_cookies(cookie_string: str) -> Dict[str, str]:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 yescode_auth 和 yescode_csrf
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串
|
||||
|
||||
Returns:
|
||||
包含 yescode_auth 和 yescode_csrf 的字典
|
||||
"""
|
||||
result: Dict[str, str] = {}
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in ("yescode_auth", "yescode_csrf"):
|
||||
result[key] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _build_cookie_header(cookie_string: str) -> str:
|
||||
"""
|
||||
从输入的 Cookie 字符串构建请求用的 Cookie header
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "yescode_auth=xxx; yescode_csrf=yyy"
|
||||
2. 仅 auth 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 auth 值
|
||||
|
||||
Returns:
|
||||
Cookie header 值
|
||||
"""
|
||||
# 如果包含 "yescode_auth=",说明是完整 Cookie 字符串
|
||||
if "yescode_auth=" in cookie_string:
|
||||
cookies = _extract_cookies(cookie_string)
|
||||
parts = []
|
||||
if "yescode_auth" in cookies:
|
||||
parts.append(f"yescode_auth={cookies['yescode_auth']}")
|
||||
if "yescode_csrf" in cookies:
|
||||
parts.append(f"yescode_csrf={cookies['yescode_csrf']}")
|
||||
return "; ".join(parts)
|
||||
# 否则认为直接是 auth 值
|
||||
return f"yescode_auth={cookie_string.strip()}"
|
||||
|
||||
|
||||
class YesCodeConnector(ProviderConnector):
|
||||
"""
|
||||
YesCode 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "YesCode Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(base_url, config)
|
||||
self._auth_cookie: Optional[str] = None
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
auth_cookie = credentials.get("auth_cookie")
|
||||
if not auth_cookie:
|
||||
self._set_error("Auth Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 构建 Cookie header
|
||||
self._auth_cookie = _build_cookie_header(auth_cookie)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._auth_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._auth_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._auth_cookie:
|
||||
request.headers["Cookie"] = self._auth_cookie
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> Dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth_cookie": {
|
||||
"type": "string",
|
||||
"title": "Auth Cookie",
|
||||
"description": "从浏览器复制的 Cookie(包含 yescode_auth 和 yescode_csrf)",
|
||||
},
|
||||
},
|
||||
"required": ["auth_cookie"],
|
||||
}
|
||||
|
||||
|
||||
class YesCodeArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
YesCode 架构预设
|
||||
|
||||
针对 YesCode 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
- 验证端点: /api/v1/user/balance
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
"""
|
||||
|
||||
architecture_id = "yescode"
|
||||
display_name = "YesCode"
|
||||
description = "YesCode 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: List[Type[ProviderConnector]] = [
|
||||
YesCodeConnector,
|
||||
]
|
||||
|
||||
supported_actions: List[Type[ProviderAction]] = [
|
||||
YesCodeBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: Dict[ProviderActionType, Dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/user/balance",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "total_balance",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> Dict[str, Any]:
|
||||
"""YesCode 使用 auth_cookie 认证"""
|
||||
return YesCodeConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点 - 使用 profile 接口获取完整信息"""
|
||||
return "/api/v1/auth/profile"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
预获取合并数据(balance + profile)
|
||||
|
||||
验证时并发调用两个接口获取完整数据。
|
||||
"""
|
||||
extra_config: Dict[str, Any] = {}
|
||||
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if not cookie_input:
|
||||
return extra_config
|
||||
|
||||
cookie_header = _build_cookie_header(cookie_input)
|
||||
|
||||
try:
|
||||
# 创建临时 client 获取合并数据
|
||||
async with httpx.AsyncClient(
|
||||
headers={"Cookie": cookie_header},
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
extra_config["_combined_data"] = combined_data
|
||||
except Exception:
|
||||
# 如果调用失败,不影响验证流程(会回退到单独调用 profile)
|
||||
pass
|
||||
|
||||
return extra_config
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
credentials: Dict[str, Any],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
构建 YesCode 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: Dict[str, str] = {}
|
||||
|
||||
# 添加 Cookie
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if cookie_input:
|
||||
headers["Cookie"] = _build_cookie_header(cookie_input)
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: Dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 YesCode 验证响应(使用预获取的合并数据)"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# 优先使用预获取的合并数据(包含 balance + profile)
|
||||
combined_data = data.get("_combined_data")
|
||||
if combined_data:
|
||||
# 检查是否有有效数据
|
||||
if "_profile_data" not in combined_data and "_balance_data" not in combined_data:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=combined_data.get("username"),
|
||||
display_name=combined_data.get("username"),
|
||||
email=combined_data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
|
||||
# 回退:仅使用 profile 数据(旧逻辑,当 prepare_verify_config 失败时)
|
||||
if "username" not in data:
|
||||
return VerifyResult(success=False, message="响应格式无效")
|
||||
|
||||
# 构造兼容格式供公共函数使用
|
||||
compat_data = {
|
||||
"pay_as_you_go_balance": data.get("pay_as_you_go_balance", 0),
|
||||
"subscription_balance": data.get("subscription_balance", 0),
|
||||
"weekly_spent_balance": data.get("current_week_spend", 0),
|
||||
"subscription_plan": data.get("subscription_plan"),
|
||||
"last_week_reset": data.get("last_week_reset"),
|
||||
"last_daily_balance_add": data.get("last_daily_balance_add"),
|
||||
}
|
||||
|
||||
extra = parse_yescode_balance_extra(compat_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=data.get("username"),
|
||||
display_name=data.get("username"),
|
||||
email=data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
@@ -9,10 +9,13 @@ from typing import Dict, List, Optional, Type
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.architectures import (
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
YesCodeArchitecture,
|
||||
)
|
||||
|
||||
|
||||
@@ -47,9 +50,12 @@ class ArchitectureRegistry:
|
||||
def _register_builtin_architectures(self) -> None:
|
||||
"""注册内置架构"""
|
||||
builtin = [
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NewApiArchitecture,
|
||||
OneApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
]
|
||||
|
||||
for arch_cls in builtin:
|
||||
|
||||
@@ -45,7 +45,7 @@ class ProviderOpsService:
|
||||
"""
|
||||
|
||||
# 凭据中需要加密的字段
|
||||
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "cookie_string", "cookies"}
|
||||
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "session_cookie", "token_cookie", "auth_cookie", "cookie_string", "cookies"}
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
@@ -412,13 +412,19 @@ class ProviderOpsService:
|
||||
|
||||
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
|
||||
|
||||
async def _cache_balance_from_verify(self, provider_id: str, quota_usd: float) -> None:
|
||||
async def _cache_balance_from_verify(
|
||||
self,
|
||||
provider_id: str,
|
||||
quota_usd: float,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
从验证结果缓存余额
|
||||
|
||||
Args:
|
||||
provider_id: Provider ID
|
||||
quota_usd: 已转换为美元的余额值
|
||||
extra: 额外信息(如窗口限额)
|
||||
"""
|
||||
cache_key = f"provider_ops:balance:{provider_id}"
|
||||
|
||||
@@ -431,7 +437,7 @@ class ProviderOpsService:
|
||||
"total_used": None,
|
||||
"total_available": quota_usd,
|
||||
"currency": "USD",
|
||||
"extra": {},
|
||||
"extra": extra or {},
|
||||
},
|
||||
"executed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"response_time_ms": None,
|
||||
@@ -609,7 +615,10 @@ class ProviderOpsService:
|
||||
|
||||
if saved_config:
|
||||
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
|
||||
sensitive_fields = ["api_key", "password", "session_token", "cookie_string", "cookies"]
|
||||
sensitive_fields = [
|
||||
"api_key", "password", "session_token", "cookie_string", "cookies",
|
||||
"token_cookie", "auth_cookie", # Cookie 认证字段
|
||||
]
|
||||
|
||||
for field in sensitive_fields:
|
||||
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
|
||||
@@ -704,7 +713,12 @@ class ProviderOpsService:
|
||||
|
||||
# 使用架构的方法构建请求
|
||||
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
|
||||
headers = architecture.build_verify_headers(config, credentials)
|
||||
|
||||
# 执行异步预处理(如获取动态 Cookie)
|
||||
extra_config = await architecture.prepare_verify_config(base_url, config, credentials)
|
||||
merged_config = {**config, **extra_config}
|
||||
|
||||
headers = architecture.build_verify_headers(merged_config, credentials)
|
||||
|
||||
logger.debug(
|
||||
f"验证认证: architecture={architecture_id}, "
|
||||
@@ -721,6 +735,12 @@ class ProviderOpsService:
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
# 将预处理获取的额外数据合并到响应中
|
||||
if "_combined_data" in merged_config:
|
||||
data["_combined_data"] = merged_config["_combined_data"]
|
||||
elif "_balance_data" in merged_config:
|
||||
data["_balance_data"] = merged_config["_balance_data"]
|
||||
|
||||
# 使用架构的方法解析响应
|
||||
result = architecture.parse_verify_response(response.status_code, data)
|
||||
result_dict = result.to_dict()
|
||||
@@ -734,7 +754,8 @@ class ProviderOpsService:
|
||||
quota_divisor = balance_config.get("quota_divisor", 1)
|
||||
# 转换为美元值后缓存
|
||||
quota_usd = result.quota / quota_divisor
|
||||
await self._cache_balance_from_verify(provider_id, quota_usd)
|
||||
# 传入 extra 信息(如窗口限额)
|
||||
await self._cache_balance_from_verify(provider_id, quota_usd, result.extra)
|
||||
|
||||
return result_dict
|
||||
|
||||
|
||||
Reference in New Issue
Block a user