feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强

- 新增三个 provider 架构:Anyrouter、Cubence、YesCode
- 前端新增对应的认证配置模板
- 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示
- 架构基类新增 prepare_verify_config 异步预处理方法
- API 返回 ops_architecture_id 字段用于前端展示
This commit is contained in:
fawney19
2026-01-19 17:18:03 +08:00
parent 3d88dfd98a
commit 6bc9cdc69d
25 changed files with 2057 additions and 20 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
.claude/ .claude/
.serena/ .serena/
.gemini*/ .gemini*/
.plans
### Python ### ### Python ###
*.db *.db

View File

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

View File

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

View File

@@ -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
}
} }
/** 签到信息 */ /** 签到信息 */

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
] ]

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

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

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

View File

@@ -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",
] ]

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

View File

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

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

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

View File

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

View File

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