feat(antigravity): 支持 Refresh Token 批量导入创建 OAuth 账号
后端新增 ValidateRefreshToken service 方法和 POST /oauth/refresh-token 端点, 前端新增 API/Composable/UI 集成,OAuthAuthorizationFlow i18n 动态化, 支持在 Antigravity 创建账号时批量粘贴 Refresh Token 自动验证并创建账号。
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user