mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 变化,验证成功后的修改需要重新验证
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user