feat(oauth): 新增 Codex account_user_id 和 organizations 字段采集、展示与判重

- 从 Codex id_token claims 和 token_response 中提取 account_user_id 和 organizations
- OAuth 判重逻辑改为优先按 account_user_id 匹配,支持同用户不同 Team 不误判
- 号池和 Provider 详情页展示组织标签、account ID 和 account_user_id
- 前端重复的 OAuth identity 工具函数提取到 utils/oauthIdentity.ts
- 后端重复的 normalize_oauth_organizations 提取到 core/provider_oauth_utils.py
This commit is contained in:
fawney19
2026-03-11 21:37:08 +08:00
parent b45f021bba
commit 0d770d1c4d
16 changed files with 546 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, ProxyConfig } from './types/provider'
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
@@ -102,6 +102,9 @@ export interface PoolKeyDetail {
oauth_invalid_at?: number | null
oauth_invalid_reason?: string | null
oauth_plan_type?: string | null
oauth_account_id?: string | null
oauth_account_user_id?: string | null
oauth_organizations?: OAuthOrganizationInfo[] | null
quota_updated_at?: number | null
health_score?: number
circuit_breaker_open?: boolean

View File

@@ -12,6 +12,13 @@ export interface ProxyConfig {
enabled?: boolean // 是否启用代理false 时保留配置但不使用)
}
export interface OAuthOrganizationInfo {
id?: string | null
title?: string | null
is_default?: boolean | null
role?: string | null
}
/**
* 请求头规则类型
* - set: 设置/覆盖请求头
@@ -264,6 +271,8 @@ export interface EndpointAPIKey {
oauth_email?: string | null // OAuth 授权的邮箱
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
oauth_invalid_at?: number | null // OAuth Token 失效时间Unix 时间戳)
oauth_invalid_reason?: string | null // OAuth Token 失效原因
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)

View File

@@ -121,6 +121,18 @@
variant="outline"
class="text-[10px] px-1 py-0 h-4 shrink-0"
>{{ key.oauth_plan_type }}</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>{{ getPrimaryOAuthOrganizationTitle(key) }}</Badge>
<Badge
v-if="key.oauth_account_id"
variant="secondary"
class="text-[10px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
>acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}</Badge>
<Badge
v-if="isBannedKey(key)"
variant="destructive"
@@ -129,6 +141,10 @@
</div>
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
<span
v-if="key.oauth_account_user_id"
:title="key.oauth_account_user_id"
>AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}</span>
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
<span v-if="key.proxy?.node_id">独立代理</span>
<span
@@ -277,6 +293,7 @@ import {
import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
type QuickSelectorValue =
| 'banned'

View File

@@ -289,6 +289,22 @@
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[10px] px-1.5 py-0 shrink-0 max-w-[120px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
variant="secondary"
class="text-[10px] px-1.5 py-0 shrink-0"
:title="key.oauth_account_id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
</Badge>
<!-- Kiro 订阅类型标签 -->
<Badge
v-if="provider.provider_type === 'kiro' && key.upstream_metadata?.kiro?.subscription_title"
@@ -303,6 +319,13 @@
<span class="text-[11px] font-mono text-muted-foreground">
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'service_account' ? '[Service Account]' : key.api_key_masked) }}
</span>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
<Button
v-if="key.auth_type === 'oauth'"
variant="ghost"
@@ -1103,6 +1126,7 @@ import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/ty
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
// 扩展端点类型,包含密钥列表
interface ProviderEndpointWithKeys extends ProviderEndpoint {

View File

@@ -0,0 +1,43 @@
import type { OAuthOrganizationInfo } from '@/api/endpoints/types/provider'
export function formatOAuthIdentityShort(
value: string | null | undefined,
head = 8,
tail = 6,
): string {
const normalized = String(value || '').trim()
if (!normalized) return ''
if (normalized.length <= head + tail + 3) return normalized
return `${normalized.slice(0, head)}...${normalized.slice(-tail)}`
}
export function getPrimaryOAuthOrganizationTitle(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
): string | null {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
const defaultOrg = organizations.find(
(org) => org?.is_default && typeof org?.title === 'string' && org.title.trim(),
)
if (defaultOrg?.title) return defaultOrg.title.trim()
const firstWithTitle = organizations.find(
(org) => typeof org?.title === 'string' && org.title.trim(),
)
return firstWithTitle?.title?.trim() || null
}
export function getOAuthOrganizationsTooltip(
value: { oauth_organizations?: OAuthOrganizationInfo[] | null } | null | undefined,
): string {
const organizations = Array.isArray(value?.oauth_organizations) ? value.oauth_organizations : []
if (organizations.length === 0) return ''
return organizations
.map((org) => {
const title =
typeof org?.title === 'string' && org.title.trim() ? org.title.trim() : '未命名组织'
const role =
typeof org?.role === 'string' && org.role.trim() ? ` (${org.role.trim()})` : ''
const suffix = org?.is_default ? ' [default]' : ''
return `${title}${role}${suffix}`
})
.join('\n')
}

View File

@@ -492,6 +492,29 @@
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
</TableCell>
@@ -794,6 +817,29 @@
>
{{ formatOAuthPlanType(key.oauth_plan_type) }}
</Badge>
<Badge
v-if="getPrimaryOAuthOrganizationTitle(key)"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0 max-w-[92px] truncate"
:title="getOAuthOrganizationsTooltip(key)"
>
{{ getPrimaryOAuthOrganizationTitle(key) }}
</Badge>
<Badge
v-if="key.oauth_account_id"
variant="secondary"
class="text-[9px] px-1 py-0 h-4 shrink-0"
:title="key.oauth_account_id"
>
acct {{ formatOAuthIdentityShort(key.oauth_account_id) }}
</Badge>
<span
v-if="key.oauth_account_user_id"
class="text-[10px] text-muted-foreground shrink-0"
:title="key.oauth_account_user_id"
>
AUID {{ formatOAuthIdentityShort(key.oauth_account_user_id, 10, 8) }}
</span>
</div>
</div>
<div class="flex items-center gap-0.5 shrink-0 flex-wrap justify-end max-w-[210px]">
@@ -1161,7 +1207,12 @@ import type {
PoolKeysPageResponse,
PoolPresetMeta,
} from '@/api/endpoints/pool'
import type { ClaudeCodeAdvancedConfig, EndpointAPIKey, PoolAdvancedConfig, ProviderWithEndpointsSummary } from '@/api/endpoints/types/provider'
import type {
ClaudeCodeAdvancedConfig,
EndpointAPIKey,
PoolAdvancedConfig,
ProviderWithEndpointsSummary,
} from '@/api/endpoints/types/provider'
import { getProvider, updateProvider } from '@/api/endpoints'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import PoolSchedulingDialog from '@/features/pool/components/PoolSchedulingDialog.vue'
@@ -1174,6 +1225,7 @@ import OAuthKeyEditDialog from '@/features/providers/components/OAuthKeyEditDial
import OAuthAccountDialog from '@/features/providers/components/OAuthAccountDialog.vue'
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
import { isAccountLevelBlockReason, classifyAccountBlockLabel, cleanAccountBlockReason } from '@/utils/accountBlock'
import { formatOAuthIdentityShort, getPrimaryOAuthOrganizationTitle, getOAuthOrganizationsTooltip } from '@/utils/oauthIdentity'
const { success, error: showError, warning: showWarning } = useToast()
const { confirm } = useConfirm()
@@ -1717,7 +1769,11 @@ function toEndpointApiKey(key: PoolKeyDetail): EndpointAPIKey {
model_include_patterns: key.model_include_patterns || [],
model_exclude_patterns: key.model_exclude_patterns || [],
oauth_expires_at: key.oauth_expires_at ?? null,
oauth_email: null,
oauth_plan_type: key.oauth_plan_type ?? null,
oauth_account_id: key.oauth_account_id ?? null,
oauth_account_user_id: key.oauth_account_user_id ?? null,
oauth_organizations: key.oauth_organizations ?? [],
oauth_invalid_at: key.oauth_invalid_at ?? null,
oauth_invalid_reason: key.oauth_invalid_reason ?? null,
proxy: key.proxy ?? null,