refactor: 重构供应商操作基类为抽象类并支持 New API Cookie 认证

主要更改:
- 重构 BalanceAction/CheckinAction 为抽象基类,子类实现具体查询逻辑
- 重构 ProviderArchitecture 认证相关方法为抽象方法
- New API 支持 Cookie 认证(与 api_key+user_id 二选一)
- 添加 NewApiBalanceAction,支持查询余额前自动签到
- 修复 session cookie gob 编码解析逻辑
- 前端认证表单支持 inline 布局、字段联动和 onFieldChange 回调
This commit is contained in:
fawney19
2026-01-20 00:55:52 +08:00
parent c24f3d47f7
commit edf8c663f1
19 changed files with 1159 additions and 676 deletions

View File

@@ -29,20 +29,40 @@ export const newApiTemplate: AuthTemplate = {
: '请填写站点地址',
required: !providerWebsite,
},
],
},
{
fields: [
{
key: 'cookie',
label: 'Cookie',
type: 'text',
placeholder: '',
required: false,
sensitive: true,
helpText: '填写 Cookie 后支持自动签到',
},
],
},
{
layout: 'inline',
fields: [
{
key: 'api_key',
label: '访问令牌 (API Key)',
type: 'password',
placeholder: 'sk-xxx',
required: true,
placeholder: '',
required: false,
sensitive: true,
flex: 3,
},
{
key: 'user_id',
label: '用户 ID',
type: 'text',
placeholder: '用户 ID',
required: true,
placeholder: '',
required: false,
flex: 1,
},
],
},
@@ -63,8 +83,10 @@ export const newApiTemplate: AuthTemplate = {
proxy: buildProxyUrl(formData),
},
credentials: {
api_key: formData.api_key,
user_id: formData.user_id,
// 敏感字段始终发送(空字符串会触发后端合并已保存的值)
api_key: formData.api_key?.trim() || '',
user_id: formData.user_id?.trim() || '',
cookie: formData.cookie?.trim() || '',
},
},
actions: {},
@@ -78,17 +100,26 @@ export const newApiTemplate: AuthTemplate = {
base_url: config?.base_url || '',
api_key: config?.connector?.credentials?.api_key || '',
user_id: config?.connector?.credentials?.user_id || '',
cookie: config?.connector?.credentials?.cookie || '',
...proxyData,
}
},
validate(formData: Record<string, any>): string | null {
if (!formData.api_key?.trim()) {
return '请填写访问令牌'
const hasApiKey = !!formData.api_key?.trim()
const hasCookie = !!formData.cookie?.trim()
const hasUserId = !!formData.user_id?.trim()
// api_key 和 cookie 至少需要一个
if (!hasApiKey && !hasCookie) {
return '访问令牌和 Cookie 至少需要填写一个'
}
if (!formData.user_id?.trim()) {
return '请填写用户 ID'
// 使用 api_key 时必须提供 user_id使用 cookie 时 user_id 可选
if (hasApiKey && !hasCookie && !hasUserId) {
return '使用访问令牌时,用户 ID 不能为空'
}
return null
},
@@ -100,4 +131,123 @@ export const newApiTemplate: AuthTemplate = {
}
return `$${usd.toFixed(4)}`
},
onFieldChange(fieldKey: string, value: any, formData: Record<string, any>): void {
// 当 cookie 变化且 user_id 为空时,尝试从 cookie 解析 user_id
if (fieldKey === 'cookie' && value && !formData.user_id?.trim()) {
const result = parseSessionCookie(value)
if (result.userId) {
formData.user_id = result.userId
}
}
},
}
interface SessionCookieResult {
userId: string | null
username: string | null
}
/**
* 从 Cookie 字符串中解析用户 ID 和用户名
*
* New API 的 session cookie 格式:
* base64(timestamp|gob_base64|signature)
*
* gob 数据中包含 id 和 username 字段
*
* 支持两种输入:
* 1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
* 2. 仅 session 值: "MTc2ODc4..."
*/
function parseSessionCookie(cookie: string): SessionCookieResult {
const result: SessionCookieResult = { userId: null, username: null }
try {
// 提取 session 值
let sessionValue = cookie.trim()
if (sessionValue.includes('session=')) {
const match = sessionValue.match(/session=([^;]+)/)
if (match) {
sessionValue = match[1]
}
}
// URL-safe base64 解码
// 补充 padding
const padding = 4 - (sessionValue.length % 4)
if (padding !== 4) {
sessionValue += '='.repeat(padding)
}
// 将 URL-safe base64 转为标准 base64
const standardBase64 = sessionValue.replace(/-/g, '+').replace(/_/g, '/')
const decoded = atob(standardBase64)
// 分割: timestamp|gob_base64|signature
const parts = decoded.split('|')
if (parts.length < 2) {
return result
}
// 解码 gob 数据(第二部分)
let gobB64 = parts[1]
const gobPadding = 4 - (gobB64.length % 4)
if (gobPadding !== 4) {
gobB64 += '='.repeat(gobPadding)
}
const gobStandardB64 = gobB64.replace(/-/g, '+').replace(/_/g, '/')
const gobData = atob(gobStandardB64)
// 解析 gob 编码的 id 字段
// 查找 "\x02id\x03int" 模式,后面跟着 gob 编码的整数
const idIntPattern = '\x02id\x03int'
const idIdx = gobData.indexOf(idIntPattern)
if (idIdx !== -1) {
// 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
const valueStart = idIdx + 7 + 2
if (valueStart < gobData.length) {
// 读取第一个字节,检查是否是 00正数标记
const firstByte = gobData.charCodeAt(valueStart)
if (firstByte === 0) {
// 下一个字节是长度标记
const marker = gobData.charCodeAt(valueStart + 1)
if (marker >= 0x80) {
// 负的表示长度: 256 - marker = 字节数
const length = 256 - marker
if (valueStart + 2 + length <= gobData.length) {
// 读取 length 字节,大端序转整数
let val = 0
for (let i = 0; i < length; i++) {
val = (val << 8) | gobData.charCodeAt(valueStart + 2 + i)
}
// gob zigzag 解码:正整数用 2*n 编码
result.userId = (val >> 1).toString()
}
}
}
}
}
// 解析 gob 编码的 username 字段
// 查找 "\x08username\x06string" 模式,后面跟着长度和字符串值
const usernamePattern = '\x08username\x06string'
const usernameIdx = gobData.indexOf(usernamePattern)
if (usernameIdx !== -1) {
// 跳过 pattern (16字节) 和类型标记 (3字节)
const lengthPos = usernameIdx + usernamePattern.length + 3
if (lengthPos < gobData.length) {
const lengthByte = gobData.charCodeAt(lengthPos)
const valueStart = lengthPos + 1
// 长度 < 128 表示直接长度编码
if (lengthByte < 128 && valueStart + lengthByte <= gobData.length) {
result.username = gobData.substring(valueStart, valueStart + lengthByte)
}
}
}
return result
} catch {
return result
}
}

View File

@@ -36,6 +36,8 @@ export interface AuthTemplateField {
options?: Array<{ value: string; label: string }>
/** 默认值 */
defaultValue?: string
/** inline 布局时的 flex 比例(默认 1 */
flex?: number
}
/**
@@ -54,6 +56,8 @@ export interface AuthTemplateFieldGroup {
hasToggle?: boolean
/** 启用开关对应的表单字段 key */
toggleKey?: string
/** 布局方式:'vertical'(默认) 或 'inline'(同行显示) */
layout?: 'vertical' | 'inline'
}
/**
@@ -118,6 +122,14 @@ export interface AuthTemplate {
* @param extra 余额 extra 字段
*/
formatBalanceExtra?(extra: Record<string, any>): BalanceExtraItem[]
/**
* 字段值变化时的回调,可用于联动填充其他字段
* @param fieldKey 变化的字段 key
* @param value 新值
* @param formData 当前表单数据(可修改)
*/
onFieldChange?(fieldKey: string, value: any, formData: Record<string, any>): void
}
/**

View File

@@ -86,9 +86,11 @@
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
class="h-8 text-sm"
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 密码/敏感输入 -->
@@ -98,6 +100,7 @@
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
class="h-8 text-sm"
@update:model-value="handleFieldChange(field.key, $event)"
/>
</div>
</div>
@@ -114,72 +117,118 @@
{{ group.title }}
</div>
<!-- 字段列表 -->
<!-- inline 布局字段横向排列 -->
<div
v-for="field in group.fields"
:key="field.key"
class="space-y-2"
v-if="group.layout === 'inline'"
class="flex gap-3"
>
<Label>
{{ field.label }}
<span
v-if="field.required"
class="text-muted-foreground/70"
>*</span>
</Label>
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
disable-autofill
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
/>
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
@update:model-value="handleFieldChange(field.key, $event)"
<div
v-for="field in group.fields"
:key="field.key"
class="space-y-2"
:style="{ flex: field.flex || 1 }"
>
<SelectTrigger>
<SelectValue :placeholder="field.placeholder || '请选择'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<Label>
{{ field.label }}
<span
v-if="field.required"
class="text-muted-foreground/70"
>*</span>
</Label>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
rows="3"
/>
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 帮助文本 -->
<p
v-if="field.helpText"
class="text-xs text-muted-foreground"
>
{{ field.helpText }}
</p>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
/>
</div>
</div>
<!-- vertical 布局默认字段垂直排列 -->
<template v-else>
<div
v-for="field in group.fields"
:key="field.key"
class="space-y-2"
>
<Label>
{{ field.label }}
<span
v-if="field.required"
class="text-muted-foreground/70"
>*</span>
</Label>
<!-- 文本输入 -->
<Input
v-if="field.type === 'text'"
v-model="formData[field.key]"
:placeholder="field.sensitive ? (sensitivePlaceholders[field.key] || field.placeholder) : field.placeholder"
:masked="field.sensitive"
disable-autofill
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 密码/敏感输入 -->
<Input
v-else-if="field.type === 'password'"
v-model="formData[field.key]"
:placeholder="sensitivePlaceholders[field.key] || field.placeholder"
masked
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 下拉选择 -->
<Select
v-else-if="field.type === 'select'"
v-model="formData[field.key]"
@update:model-value="handleFieldChange(field.key, $event)"
>
<SelectTrigger>
<SelectValue :placeholder="field.placeholder || '请选择'" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
v-model="formData[field.key]"
:placeholder="field.placeholder"
rows="3"
@update:model-value="handleFieldChange(field.key, $event)"
/>
<!-- 帮助文本 -->
<p
v-if="field.helpText"
class="text-xs text-muted-foreground"
>
{{ field.helpText }}
</p>
</div>
</template>
</template>
</template>
</template>
@@ -235,7 +284,7 @@ import {
} from '../auth-templates'
// 敏感字段列表(用于验证和加载配置时的特殊处理)
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookies', 'proxy_password'] as const
const SENSITIVE_FIELDS = ['api_key', 'password', 'session_token', 'session_cookie', 'token_cookie', 'auth_cookie', 'cookie_string', 'cookie', 'proxy_password'] as const
const props = defineProps<{
open: boolean
@@ -321,9 +370,15 @@ function handleTemplateChange() {
formChanged.value = true
}
function handleFieldChange(_fieldKey: string, _value: any) {
function handleFieldChange(fieldKey: string, value: any) {
// 标记表单已变动
formChanged.value = true
// 调用模板的 onFieldChange 回调
const template = selectedTemplate.value
if (template?.onFieldChange) {
template.onFieldChange(fieldKey, value, formData.value)
}
}
// 监听 formData 变化,验证成功后的修改需要重新验证

View File

@@ -190,12 +190,12 @@
class="flex items-center gap-1.5"
>
<span
v-if="getProviderCheckin(provider.id)?.success === true"
v-if="getProviderCheckin(provider.id)?.success !== false"
class="text-[10px] text-muted-foreground/60"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>
<span
v-else-if="getProviderCheckin(provider.id)?.success === false"
v-else
class="text-[10px] text-destructive/70"
:title="getProviderCheckin(provider.id)?.message"
>签到失败</span>
@@ -431,7 +431,7 @@
余额 <span class="font-semibold text-foreground/90">{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}</span>
<!-- 签到状态显示 -->
<span
v-if="getProviderCheckin(provider.id)?.success === true"
v-if="getProviderCheckin(provider.id) && getProviderCheckin(provider.id)?.success !== false"
class="ml-1 text-muted-foreground"
:title="getProviderCheckin(provider.id)?.message"
>已签到</span>