mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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/
|
.claude/
|
||||||
.serena/
|
.serena/
|
||||||
.gemini*/
|
.gemini*/
|
||||||
|
.plans
|
||||||
|
|
||||||
### Python ###
|
### Python ###
|
||||||
*.db
|
*.db
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ RUN printf '%s\n' \
|
|||||||
' return 404;' \
|
' return 404;' \
|
||||||
' }' \
|
' }' \
|
||||||
'' \
|
'' \
|
||||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
' location ~ ^/(dashboard|admin|login|auth)(/|$) {' \
|
||||||
' try_files $uri $uri/ /index.html;' \
|
' 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[]
|
api_formats: string[]
|
||||||
endpoint_health_details: EndpointHealthDetail[]
|
endpoint_health_details: EndpointHealthDetail[]
|
||||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||||
|
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,11 @@ export interface BalanceInfo {
|
|||||||
total_available: number | null
|
total_available: number | null
|
||||||
expires_at: string | null
|
expires_at: string | null
|
||||||
currency: string
|
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 type { AuthTemplate, AuthTemplateRegistry } from './types'
|
||||||
|
import { anyrouterTemplate } from './anyrouter'
|
||||||
|
import { cubenceTemplate } from './cubence'
|
||||||
import { newApiTemplate } from './new-api'
|
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 * from './types'
|
||||||
|
export { anyrouterTemplate } from './anyrouter'
|
||||||
|
export { cubenceTemplate } from './cubence'
|
||||||
export { newApiTemplate } from './new-api'
|
export { newApiTemplate } from './new-api'
|
||||||
|
export { yescodeTemplate } from './yescode'
|
||||||
|
|||||||
@@ -103,6 +103,29 @@ export interface AuthTemplate {
|
|||||||
* @param quota quota 值
|
* @param quota quota 值
|
||||||
*/
|
*/
|
||||||
formatQuota?(quota: number): string
|
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'
|
} 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<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -427,11 +427,11 @@ function loadFromConfig(config: any) {
|
|||||||
|
|
||||||
hasExistingConfig.value = true
|
hasExistingConfig.value = true
|
||||||
|
|
||||||
// 目前只支持 new_api 模板
|
// 根据已保存的 architecture_id 选择对应模板,不存在则回退到 new_api
|
||||||
selectedTemplateId.value = 'new_api'
|
const architectureId = config.architecture_id || 'new_api'
|
||||||
|
selectedTemplateId.value = authTemplateRegistry.get(architectureId) ? architectureId : 'new_api'
|
||||||
// 使用模板解析配置
|
|
||||||
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
const template = authTemplateRegistry.get(selectedTemplateId.value)
|
||||||
|
|
||||||
if (template) {
|
if (template) {
|
||||||
const parsedData = template.parseConfig(config)
|
const parsedData = template.parseConfig(config)
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
|
<TableHead class="w-[180px] h-11 font-medium text-foreground/80">
|
||||||
提供商信息
|
提供商信息
|
||||||
</TableHead>
|
</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>
|
||||||
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
|
<TableHead class="w-[120px] h-11 font-medium text-foreground/80 text-center">
|
||||||
@@ -142,15 +142,73 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="py-3.5 text-center">
|
<TableCell class="py-3.5">
|
||||||
<!-- 显示从上游 API 查询的余额 -->
|
<!-- 显示从上游 API 查询的余额 -->
|
||||||
<div
|
<div
|
||||||
v-if="provider.ops_configured && getProviderBalance(provider.id)"
|
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)) }}
|
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||||
</span>
|
</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>
|
||||||
<!-- 显示本地配置的月度配额 -->
|
<!-- 显示本地配置的月度配额 -->
|
||||||
<div
|
<div
|
||||||
@@ -371,6 +429,25 @@
|
|||||||
class="text-muted-foreground"
|
class="text-muted-foreground"
|
||||||
>
|
>
|
||||||
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
|
余额 <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>
|
||||||
<!-- 本地配额 -->
|
<!-- 本地配额 -->
|
||||||
<span
|
<span
|
||||||
@@ -459,7 +536,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
@@ -496,6 +573,7 @@ import {
|
|||||||
import { adminApi } from '@/api/admin'
|
import { adminApi } from '@/api/admin'
|
||||||
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
|
import { batchQueryBalance, type ActionResultResponse } from '@/api/providerOps'
|
||||||
import { formatBillingType } from '@/utils/format'
|
import { formatBillingType } from '@/utils/format'
|
||||||
|
import { authTemplateRegistry, type BalanceExtraItem } from '@/features/providers/auth-templates'
|
||||||
|
|
||||||
const { error: showError, success: showSuccess } = useToast()
|
const { error: showError, success: showSuccess } = useToast()
|
||||||
const { confirmDanger } = useConfirm()
|
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 {
|
function formatBalanceDisplay(balance: { available: number | null; currency: string } | null): string {
|
||||||
if (!balance || balance.available == null) {
|
if (!balance || balance.available == null) {
|
||||||
@@ -667,6 +785,48 @@ function formatBalanceDisplay(balance: { available: number | null; currency: str
|
|||||||
return `${symbol}${balance.available.toFixed(2)}`
|
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 {
|
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(() => {
|
onMounted(() => {
|
||||||
loadProviders()
|
loadProviders()
|
||||||
loadPriorityMode()
|
loadPriorityMode()
|
||||||
|
// 每秒更新一次倒计时
|
||||||
|
tickInterval = setInterval(() => {
|
||||||
|
tickCounter.value++
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -290,7 +290,9 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
|
|||||||
]
|
]
|
||||||
|
|
||||||
# 检查是否配置了 Provider Ops(余额监控等)
|
# 检查是否配置了 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(
|
return ProviderWithEndpointsSummary(
|
||||||
id=provider.id,
|
id=provider.id,
|
||||||
@@ -318,6 +320,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
|
|||||||
api_formats=api_formats,
|
api_formats=api_formats,
|
||||||
endpoint_health_details=endpoint_health_details,
|
endpoint_health_details=endpoint_health_details,
|
||||||
ops_configured=ops_configured,
|
ops_configured=ops_configured,
|
||||||
|
ops_architecture_id=ops_architecture_id,
|
||||||
created_at=provider.created_at,
|
created_at=provider.created_at,
|
||||||
updated_at=provider.updated_at,
|
updated_at=provider.updated_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -641,6 +641,9 @@ class ProviderWithEndpointsSummary(BaseModel):
|
|||||||
|
|
||||||
# Provider Ops 配置状态
|
# Provider Ops 配置状态
|
||||||
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
|
ops_configured: bool = Field(default=False, description="是否配置了扩展操作(余额监控等)")
|
||||||
|
ops_architecture_id: Optional[str] = Field(
|
||||||
|
default=None, description="扩展操作使用的架构 ID(如 cubence, anyrouter)"
|
||||||
|
)
|
||||||
|
|
||||||
# 时间戳
|
# 时间戳
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
Provider 操作模块
|
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.balance import BalanceAction
|
||||||
from src.services.provider_ops.actions.base import ProviderAction
|
from src.services.provider_ops.actions.base import ProviderAction
|
||||||
from src.services.provider_ops.actions.checkin import CheckinAction
|
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__ = [
|
__all__ = [
|
||||||
"ProviderAction",
|
"ProviderAction",
|
||||||
"BalanceAction",
|
"BalanceAction",
|
||||||
"CheckinAction",
|
"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,
|
ProviderConnector,
|
||||||
VerifyResult,
|
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.generic_api import GenericApiArchitecture
|
||||||
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.one_api import OneApiArchitecture
|
||||||
|
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProviderArchitecture",
|
"ProviderArchitecture",
|
||||||
"ProviderConnector",
|
"ProviderConnector",
|
||||||
"VerifyResult",
|
"VerifyResult",
|
||||||
|
"AnyrouterArchitecture",
|
||||||
|
"CubenceArchitecture",
|
||||||
"GenericApiArchitecture",
|
"GenericApiArchitecture",
|
||||||
"NewApiArchitecture",
|
"NewApiArchitecture",
|
||||||
"OneApiArchitecture",
|
"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"
|
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(
|
def build_verify_headers(
|
||||||
self,
|
self,
|
||||||
config: Dict[str, Any],
|
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.core.logger import logger
|
||||||
from src.services.provider_ops.architectures import (
|
from src.services.provider_ops.architectures import (
|
||||||
|
AnyrouterArchitecture,
|
||||||
|
CubenceArchitecture,
|
||||||
GenericApiArchitecture,
|
GenericApiArchitecture,
|
||||||
NewApiArchitecture,
|
NewApiArchitecture,
|
||||||
OneApiArchitecture,
|
OneApiArchitecture,
|
||||||
ProviderArchitecture,
|
ProviderArchitecture,
|
||||||
|
YesCodeArchitecture,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,9 +50,12 @@ class ArchitectureRegistry:
|
|||||||
def _register_builtin_architectures(self) -> None:
|
def _register_builtin_architectures(self) -> None:
|
||||||
"""注册内置架构"""
|
"""注册内置架构"""
|
||||||
builtin = [
|
builtin = [
|
||||||
|
AnyrouterArchitecture,
|
||||||
|
CubenceArchitecture,
|
||||||
GenericApiArchitecture,
|
GenericApiArchitecture,
|
||||||
NewApiArchitecture,
|
NewApiArchitecture,
|
||||||
OneApiArchitecture,
|
OneApiArchitecture,
|
||||||
|
YesCodeArchitecture,
|
||||||
]
|
]
|
||||||
|
|
||||||
for arch_cls in builtin:
|
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):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -412,13 +412,19 @@ class ProviderOpsService:
|
|||||||
|
|
||||||
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
|
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:
|
Args:
|
||||||
provider_id: Provider ID
|
provider_id: Provider ID
|
||||||
quota_usd: 已转换为美元的余额值
|
quota_usd: 已转换为美元的余额值
|
||||||
|
extra: 额外信息(如窗口限额)
|
||||||
"""
|
"""
|
||||||
cache_key = f"provider_ops:balance:{provider_id}"
|
cache_key = f"provider_ops:balance:{provider_id}"
|
||||||
|
|
||||||
@@ -431,7 +437,7 @@ class ProviderOpsService:
|
|||||||
"total_used": None,
|
"total_used": None,
|
||||||
"total_available": quota_usd,
|
"total_available": quota_usd,
|
||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"extra": {},
|
"extra": extra or {},
|
||||||
},
|
},
|
||||||
"executed_at": datetime.now(timezone.utc).isoformat(),
|
"executed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"response_time_ms": None,
|
"response_time_ms": None,
|
||||||
@@ -609,7 +615,10 @@ class ProviderOpsService:
|
|||||||
|
|
||||||
if saved_config:
|
if saved_config:
|
||||||
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
|
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:
|
for field in sensitive_fields:
|
||||||
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
|
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
|
||||||
@@ -704,7 +713,12 @@ class ProviderOpsService:
|
|||||||
|
|
||||||
# 使用架构的方法构建请求
|
# 使用架构的方法构建请求
|
||||||
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
|
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(
|
logger.debug(
|
||||||
f"验证认证: architecture={architecture_id}, "
|
f"验证认证: architecture={architecture_id}, "
|
||||||
@@ -721,6 +735,12 @@ class ProviderOpsService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
data = {}
|
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 = architecture.parse_verify_response(response.status_code, data)
|
||||||
result_dict = result.to_dict()
|
result_dict = result.to_dict()
|
||||||
@@ -734,7 +754,8 @@ class ProviderOpsService:
|
|||||||
quota_divisor = balance_config.get("quota_divisor", 1)
|
quota_divisor = balance_config.get("quota_divisor", 1)
|
||||||
# 转换为美元值后缓存
|
# 转换为美元值后缓存
|
||||||
quota_usd = result.quota / quota_divisor
|
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
|
return result_dict
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user