feat(grok): 支持 Web SSO 批量导入并转换为 Build OAuth

新增 Grok Web SSO → xAI Device Flow → Grok Build OAuth 导入链路,
支持管理员批量粘贴 SSO key 创建 OAuth 账号。

- 后端:ConvertSSOToBuild、ConvertFromSSO、POST /admin/grok/sso-to-oauth
- 批量:3 worker 并发,失败跳过并汇总 created/failed,worker panic recover
- 无 refresh_token 时写入 expires_at 并强制 auto_pause_on_expired
- 前端:SSO Cookie 导入入口、动态超时、中英文案、部分成功不关弹窗
- 测试:pkg/service/handler/前端超时单测;本地 Docker 真实 SSO e2e 通过
This commit is contained in:
jinfeijie bot
2026-07-14 01:09:07 +08:00
parent 7d239d62e8
commit ad4bf5c60d
17 changed files with 1316 additions and 35 deletions
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { post } = vi.hoisted(() => ({
post: vi.fn(),
}))
vi.mock('@/api/client', () => ({
apiClient: { post },
}))
import { createFromSSO, getGrokSSOImportTimeout } from '@/api/admin/grok'
describe('admin Grok SSO import API', () => {
beforeEach(() => {
post.mockReset()
post.mockResolvedValue({ data: { created: [], failed: [] } })
})
it.each([
[1, 180_000],
[3, 180_000],
[4, 270_000],
[7, 360_000],
])('uses a timeout sized for %i keys', async (keyCount, expectedTimeout) => {
expect(getGrokSSOImportTimeout(keyCount)).toBe(expectedTimeout)
await createFromSSO({
sso_tokens: Array.from({ length: keyCount }, (_, index) => `sso-${index + 1}`),
})
expect(post).toHaveBeenCalledWith(
'/admin/grok/sso-to-oauth',
expect.objectContaining({ sso_tokens: expect.any(Array) }),
{ timeout: expectedTimeout },
)
})
})
+50 -1
View File
@@ -34,11 +34,51 @@ export interface GrokTokenInfo {
scope?: string
client_id?: string
email?: string
sub?: string
team_id?: string
subscription_tier?: string
entitlement_status?: string
[key: string]: unknown
}
export interface GrokSSOToOAuthRequest {
sso_tokens: string[]
name?: string
notes?: string | null
proxy_id?: number | null
group_ids?: number[]
credentials?: Record<string, unknown>
extra?: Record<string, unknown>
concurrency?: number
load_factor?: number
priority?: number
rate_multiplier?: number
expires_at?: number | null
auto_pause_on_expired?: boolean
}
export interface GrokSSOToOAuthItemResult {
index: number
name?: string
email?: string
account?: unknown
error?: string
}
export interface GrokSSOToOAuthResponse {
created: GrokSSOToOAuthItemResult[]
failed: GrokSSOToOAuthItemResult[]
}
const GROK_SSO_IMPORT_CONCURRENCY = 3
const GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS = 90_000
const GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS = 90_000
export function getGrokSSOImportTimeout(keyCount: number): number {
const batches = Math.ceil(Math.max(1, keyCount) / GROK_SSO_IMPORT_CONCURRENCY)
return batches * GROK_SSO_IMPORT_TIMEOUT_PER_BATCH_MS + GROK_SSO_IMPORT_TIMEOUT_BUFFER_MS
}
export interface GrokQuotaWindow {
limit?: number | null
remaining?: number | null
@@ -119,4 +159,13 @@ export async function resetQuota(id: number): Promise<GrokQuotaResetResult> {
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota }
export async function createFromSSO(payload: GrokSSOToOAuthRequest): Promise<GrokSSOToOAuthResponse> {
const { data } = await apiClient.post<GrokSSOToOAuthResponse>(
'/admin/grok/sso-to-oauth',
payload,
{ timeout: getGrokSSOImportTimeout(payload.sso_tokens.length) }
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshGrokToken, queryQuota, resetQuota, createFromSSO }