feat(antigravity): 支持 Refresh Token 批量导入创建 OAuth 账号

后端新增 ValidateRefreshToken service 方法和 POST /oauth/refresh-token 端点,
前端新增 API/Composable/UI 集成,OAuthAuthorizationFlow i18n 动态化,
支持在 Antigravity 创建账号时批量粘贴 Refresh Token 自动验证并创建账号。
This commit is contained in:
Tian
2026-02-10 23:57:18 +08:00
parent ae6fed15cc
commit c8f87a9c92
9 changed files with 235 additions and 17 deletions

View File

@@ -65,3 +65,27 @@ func (h *AntigravityOAuthHandler) ExchangeCode(c *gin.Context) {
response.Success(c, tokenInfo) response.Success(c, tokenInfo)
} }
// AntigravityRefreshTokenRequest represents the request for validating Antigravity refresh token
type AntigravityRefreshTokenRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
ProxyID *int64 `json:"proxy_id"`
}
// RefreshToken validates an Antigravity refresh token and returns full token info
// POST /api/v1/admin/antigravity/oauth/refresh-token
func (h *AntigravityOAuthHandler) RefreshToken(c *gin.Context) {
var req AntigravityRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求无效: "+err.Error())
return
}
tokenInfo, err := h.antigravityOAuthService.ValidateRefreshToken(c.Request.Context(), req.RefreshToken, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}

View File

@@ -281,6 +281,7 @@ func registerAntigravityOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers)
{ {
antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL) antigravity.POST("/oauth/auth-url", h.Admin.AntigravityOAuth.GenerateAuthURL)
antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode) antigravity.POST("/oauth/exchange-code", h.Admin.AntigravityOAuth.ExchangeCode)
antigravity.POST("/oauth/refresh-token", h.Admin.AntigravityOAuth.RefreshToken)
} }
} }

View File

@@ -192,6 +192,43 @@ func (s *AntigravityOAuthService) RefreshToken(ctx context.Context, refreshToken
return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr) return nil, fmt.Errorf("token 刷新失败 (重试后): %w", lastErr)
} }
// ValidateRefreshToken 用 refresh token 验证并获取完整的 token 信息(含 email 和 project_id
func (s *AntigravityOAuthService) ValidateRefreshToken(ctx context.Context, refreshToken string, proxyID *int64) (*AntigravityTokenInfo, error) {
var proxyURL string
if proxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *proxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
}
}
// 刷新 token
tokenInfo, err := s.RefreshToken(ctx, refreshToken, proxyURL)
if err != nil {
return nil, err
}
// 获取用户信息email
client := antigravity.NewClient(proxyURL)
userInfo, err := client.GetUserInfo(ctx, tokenInfo.AccessToken)
if err != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取用户信息失败: %v\n", err)
} else {
tokenInfo.Email = userInfo.Email
}
// 获取 project_id容错失败不阻塞
projectID, loadErr := s.loadProjectIDWithRetry(ctx, tokenInfo.AccessToken, proxyURL, 3)
if loadErr != nil {
fmt.Printf("[AntigravityOAuth] 警告: 获取 project_id 失败(重试后): %v\n", loadErr)
tokenInfo.ProjectIDMissing = true
} else {
tokenInfo.ProjectID = projectID
}
return tokenInfo, nil
}
func isNonRetryableAntigravityOAuthError(err error) bool { func isNonRetryableAntigravityOAuthError(err error) bool {
msg := err.Error() msg := err.Error()
nonRetryable := []string{ nonRetryable := []string{

View File

@@ -53,4 +53,18 @@ export async function exchangeCode(
return data return data
} }
export default { generateAuthUrl, exchangeCode } export async function refreshAntigravityToken(
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo> {
const payload: Record<string, any> = { refresh_token: refreshToken }
if (proxyId) payload.proxy_id = proxyId
const { data } = await apiClient.post<AntigravityTokenInfo>(
'/admin/antigravity/oauth/refresh-token',
payload
)
return data
}
export default { generateAuthUrl, exchangeCode, refreshAntigravityToken }

View File

@@ -1647,12 +1647,12 @@
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id" :show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'" :allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'" :show-cookie-option="form.platform === 'anthropic'"
:show-refresh-token-option="form.platform === 'openai'" :show-refresh-token-option="form.platform === 'openai' || form.platform === 'antigravity'"
:platform="form.platform" :platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'" :show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl" @generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth" @cookie-auth="handleCookieAuth"
@validate-refresh-token="handleOpenAIValidateRT" @validate-refresh-token="handleValidateRefreshToken"
/> />
</div> </div>
@@ -2802,6 +2802,14 @@ const handleGenerateUrl = async () => {
} }
} }
const handleValidateRefreshToken = (rt: string) => {
if (form.platform === 'openai') {
handleOpenAIValidateRT(rt)
} else if (form.platform === 'antigravity') {
handleAntigravityValidateRT(rt)
}
}
const formatDateTimeLocal = formatDateTimeLocalInput const formatDateTimeLocal = formatDateTimeLocalInput
const parseDateTimeLocal = parseDateTimeLocalInput const parseDateTimeLocal = parseDateTimeLocalInput
@@ -2950,6 +2958,95 @@ const handleOpenAIValidateRT = async (refreshTokenInput: string) => {
} }
} }
// Antigravity 手动 RT 批量验证和创建
const handleAntigravityValidateRT = async (refreshTokenInput: string) => {
if (!refreshTokenInput.trim()) return
// Parse multiple refresh tokens (one per line)
const refreshTokens = refreshTokenInput
.split('\n')
.map((rt) => rt.trim())
.filter((rt) => rt)
if (refreshTokens.length === 0) {
antigravityOAuth.error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return
}
antigravityOAuth.loading.value = true
antigravityOAuth.error.value = ''
let successCount = 0
let failedCount = 0
const errors: string[] = []
try {
for (let i = 0; i < refreshTokens.length; i++) {
try {
const tokenInfo = await antigravityOAuth.validateRefreshToken(
refreshTokens[i],
form.proxy_id
)
if (!tokenInfo) {
failedCount++
errors.push(`#${i + 1}: ${antigravityOAuth.error.value || 'Validation failed'}`)
antigravityOAuth.error.value = ''
continue
}
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
// Generate account name with index for batch
const accountName = refreshTokens.length > 1 ? `${form.name} #${i + 1}` : form.name
// Note: Antigravity doesn't have buildExtraInfo, so we pass empty extra or rely on credentials
await adminAPI.accounts.create({
name: accountName,
notes: form.notes,
platform: 'antigravity',
type: 'oauth',
credentials,
extra: {},
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
rate_multiplier: form.rate_multiplier,
group_ids: form.group_ids,
expires_at: form.expires_at,
auto_pause_on_expired: autoPauseOnExpired.value
})
successCount++
} catch (error: any) {
failedCount++
const errMsg = error.response?.data?.detail || error.message || 'Unknown error'
errors.push(`#${i + 1}: ${errMsg}`)
}
}
// Show results
if (successCount > 0 && failedCount === 0) {
appStore.showSuccess(
refreshTokens.length > 1
? t('admin.accounts.oauth.batchSuccess', { count: successCount })
: t('admin.accounts.accountCreated')
)
emit('created')
handleClose()
} else if (successCount > 0 && failedCount > 0) {
appStore.showWarning(
t('admin.accounts.oauth.batchPartialSuccess', { success: successCount, failed: failedCount })
)
antigravityOAuth.error.value = errors.join('\n')
emit('created')
} else {
antigravityOAuth.error.value = errors.join('\n')
appStore.showError(t('admin.accounts.oauth.batchFailed'))
}
} finally {
antigravityOAuth.loading.value = false
}
}
// Gemini OAuth 授权码兑换 // Gemini OAuth 授权码兑换
const handleGeminiExchange = async (authCode: string) => { const handleGeminiExchange = async (authCode: string) => {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return if (!authCode.trim() || !geminiOAuth.sessionId.value) return

View File

@@ -45,19 +45,19 @@
class="text-blue-600 focus:ring-blue-500" class="text-blue-600 focus:ring-blue-500"
/> />
<span class="text-sm text-blue-900 dark:text-blue-200">{{ <span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.openai.refreshTokenAuth') t(getOAuthKey('refreshTokenAuth'))
}}</span> }}</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Refresh Token Input (OpenAI only) --> <!-- Refresh Token Input (OpenAI / Antigravity) -->
<div v-if="inputMethod === 'refresh_token'" class="space-y-4"> <div v-if="inputMethod === 'refresh_token'" class="space-y-4">
<div <div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80" class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
> >
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300"> <p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.openai.refreshTokenDesc') }} {{ t(getOAuthKey('refreshTokenDesc')) }}
</p> </p>
<!-- Refresh Token Input --> <!-- Refresh Token Input -->
@@ -78,7 +78,7 @@
v-model="refreshTokenInput" v-model="refreshTokenInput"
rows="3" rows="3"
class="input w-full resize-y font-mono text-sm" class="input w-full resize-y font-mono text-sm"
:placeholder="t('admin.accounts.oauth.openai.refreshTokenPlaceholder')" :placeholder="t(getOAuthKey('refreshTokenPlaceholder'))"
></textarea> ></textarea>
<p <p
v-if="parsedRefreshTokenCount > 1" v-if="parsedRefreshTokenCount > 1"
@@ -128,8 +128,8 @@
<Icon v-else name="sparkles" size="sm" class="mr-2" /> <Icon v-else name="sparkles" size="sm" class="mr-2" />
{{ {{
loading loading
? t('admin.accounts.oauth.openai.validating') ? t(getOAuthKey('validating'))
: t('admin.accounts.oauth.openai.validateAndCreate') : t(getOAuthKey('validateAndCreate'))
}} }}
</button> </button>
</div> </div>

View File

@@ -83,6 +83,35 @@ export function useAntigravityOAuth() {
} }
} }
const validateRefreshToken = async (
refreshToken: string,
proxyId?: number | null
): Promise<AntigravityTokenInfo | null> => {
if (!refreshToken.trim()) {
error.value = t('admin.accounts.oauth.antigravity.pleaseEnterRefreshToken')
return null
}
loading.value = true
error.value = ''
try {
const tokenInfo = await adminAPI.antigravity.refreshAntigravityToken(
refreshToken.trim(),
proxyId
)
return tokenInfo as AntigravityTokenInfo
} catch (err: any) {
error.value =
err.response?.data?.detail || t('admin.accounts.oauth.antigravity.failedToValidateRT')
// Don't show global error toast for batch validation to avoid spamming
// appStore.showError(error.value)
return null
} finally {
loading.value = false
}
}
const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => { const buildCredentials = (tokenInfo: AntigravityTokenInfo): Record<string, unknown> => {
let expiresAt: string | undefined let expiresAt: string | undefined
if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) { if (typeof tokenInfo.expires_at === 'number' && Number.isFinite(tokenInfo.expires_at)) {
@@ -110,6 +139,7 @@ export function useAntigravityOAuth() {
resetState, resetState,
generateAuthUrl, generateAuthUrl,
exchangeAuthCode, exchangeAuthCode,
validateRefreshToken,
buildCredentials buildCredentials
} }
} }

View File

@@ -1774,13 +1774,20 @@ export default {
authCode: 'Authorization URL or Code', authCode: 'Authorization URL or Code',
authCodePlaceholder: authCodePlaceholder:
'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value', 'Option 1: Copy the complete URL\n(http://localhost:xxx/auth/callback?code=...)\nOption 2: Copy only the code parameter value',
authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect', authCodeHint: 'You can copy the entire URL or just the code parameter value, the system will auto-detect',
failedToGenerateUrl: 'Failed to generate Antigravity auth URL', failedToGenerateUrl: 'Failed to generate Antigravity auth URL',
missingExchangeParams: 'Missing code, session ID, or state', missingExchangeParams: 'Missing code, session ID, or state',
failedToExchangeCode: 'Failed to exchange Antigravity auth code' failedToExchangeCode: 'Failed to exchange Antigravity auth code',
} // Refresh Token auth
}, refreshTokenAuth: 'Manual RT',
// Gemini specific (platform-wide) refreshTokenDesc: 'Enter your existing Antigravity Refresh Token. Supports batch input (one per line). The system will automatically validate and create accounts.',
refreshTokenPlaceholder: 'Paste your Antigravity Refresh Token...\nSupports multiple tokens, one per line',
validating: 'Validating...',
validateAndCreate: 'Validate & Create',
pleaseEnterRefreshToken: 'Please enter Refresh Token',
failedToValidateRT: 'Failed to validate Refresh Token'
}
}, // Gemini specific (platform-wide)
gemini: { gemini: {
helpButton: 'Help', helpButton: 'Help',
helpDialog: { helpDialog: {

View File

@@ -1913,7 +1913,15 @@ export default {
authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别', authCodeHint: '您可以直接复制整个链接或仅复制 code 参数值,系统会自动识别',
failedToGenerateUrl: '生成 Antigravity 授权链接失败', failedToGenerateUrl: '生成 Antigravity 授权链接失败',
missingExchangeParams: '缺少 code / session_id / state', missingExchangeParams: '缺少 code / session_id / state',
failedToExchangeCode: 'Antigravity 授权码兑换失败' failedToExchangeCode: 'Antigravity 授权码兑换失败',
// Refresh Token auth
refreshTokenAuth: '手动输入 RT',
refreshTokenDesc: '输入您已有的 Antigravity Refresh Token支持批量输入每行一个系统将自动验证并创建账号。',
refreshTokenPlaceholder: '粘贴您的 Antigravity Refresh Token...\n支持多个每行一个',
validating: '验证中...',
validateAndCreate: '验证并创建账号',
pleaseEnterRefreshToken: '请输入 Refresh Token',
failedToValidateRT: '验证 Refresh Token 失败'
} }
}, },
// Gemini specific (platform-wide) // Gemini specific (platform-wide)