diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 282ceede..4f566a8b 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -981,6 +981,100 @@ func (h *AccountHandler) Refresh(c *gin.Context) { response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount)) } +// ApplyOAuthCredentialsRequest is the payload for persisting re-authorized OAuth credentials. +type ApplyOAuthCredentialsRequest struct { + Type string `json:"type" binding:"required,oneof=oauth setup-token"` + Credentials map[string]any `json:"credentials" binding:"required"` + Extra map[string]any `json:"extra"` +} + +// ApplyOAuthCredentials 将"重新授权"得到的新凭据原子落库。 +// POST /api/v1/admin/accounts/:id/apply-oauth-credentials +// +// 与通用 PUT /:id (Update) 接口的关键区别: +// - 仅接收 type / credentials / extra 三个字段(不接受 concurrency / rpm / quota_* 等可能误传的字段) +// - Extra 走 UpdateAccountExtra(JSONB key 级合并),**绝不**全量覆盖; +// 避免 base_rpm / window_cost_limit / max_sessions / quota_* / privacy_mode +// 等持久化配置在重新授权后丢失 +// - 内置 ClearError + InvalidateToken,避免前端额外两次调用, +// 并修复旧路径未失效 token 缓存导致重新授权后立即 401 的隐性 bug +// +// 与 /refresh 的区别:/refresh 用现有 refresh_token 换 access_token(无用户交互), +// 本接口承接前端完成完整 OAuth 流程后的落库步骤。 +func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + + var req ApplyOAuthCredentialsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + ctx := c.Request.Context() + + // 预检查账号存在 + OAuth 类型(与 Refresh handler 语义一致,提供更友好的错误信息)。 + existing, err := h.adminService.GetAccount(ctx, accountID) + if err != nil { + response.NotFound(c, "Account not found") + return + } + if !existing.IsOAuth() { + response.ErrorFrom(c, infraerrors.BadRequest("NOT_OAUTH", "cannot apply oauth credentials to non-OAuth account")) + return + } + + updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{ + Type: req.Type, + Credentials: req.Credentials, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + + // 增量合并 Extra(JSONB key 级 merge,绝不覆盖 base_rpm / window_cost_limit / + // max_sessions / quota_* / privacy_mode 等持久化键)。 + // best-effort:失败仅记日志;下方 ClearAccountError 会从 DB 重新读取最新 account, + // 因此响应里的 extra 始终以 DB 为准——这里不需要手动维护内存快照。 + if len(req.Extra) > 0 { + if extraErr := h.adminService.UpdateAccountExtra(ctx, accountID, req.Extra); extraErr != nil { + extraKeys := make([]string, 0, len(req.Extra)) + for k := range req.Extra { + extraKeys = append(extraKeys, k) + } + slog.Error("apply_oauth_credentials.update_extra_failed", + "account_id", accountID, + "extra_keys", extraKeys, + "err", extraErr, + ) + } + } + + if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil { + slog.Warn("apply_oauth_credentials.clear_error_failed", + "account_id", accountID, + "err", clearErr, + ) + } else if cleared != nil { + updatedAccount = cleared + } + + if h.tokenCacheInvalidator != nil && updatedAccount.IsOAuth() { + if invalidateErr := h.tokenCacheInvalidator.InvalidateToken(ctx, updatedAccount); invalidateErr != nil { + slog.Warn("apply_oauth_credentials.invalidate_token_failed", + "account_id", accountID, + "err", invalidateErr, + ) + } + } + + response.Success(c, h.buildAccountResponseWithRuntime(ctx, updatedAccount)) +} + // GetStats handles getting account statistics // GET /api/v1/admin/accounts/:id/stats func (h *AccountHandler) GetStats(c *gin.Context) { diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index d00c2259..65b71492 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -349,6 +349,10 @@ func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *s return &account, nil } +func (s *stubAdminService) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + return nil +} + func (s *stubAdminService) DeleteAccount(ctx context.Context, id int64) error { return nil } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 5d62a7ab..349c520c 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -288,6 +288,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState) accounts.POST("/:id/refresh", h.Admin.Account.Refresh) + accounts.POST("/:id/apply-oauth-credentials", h.Admin.Account.ApplyOAuthCredentials) accounts.POST("/:id/set-privacy", h.Admin.Account.SetPrivacy) accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier) accounts.GET("/:id/stats", h.Admin.Account.GetStats) diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 44684206..fc8f3fbb 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -72,6 +72,9 @@ type AdminService interface { GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) + // UpdateAccountExtra 仅对 Extra 做 JSONB 增量合并(key 级覆盖),不会影响其它字段或运行态键。 + // 用于刷新流程持久化 account_uuid / org_uuid 等少量键,避免被全量快照覆盖。 + UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error DeleteAccount(ctx context.Context, id int64) error RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) ClearAccountError(ctx context.Context, id int64) (*Account, error) @@ -2587,6 +2590,15 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U return updated, nil } +// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 +// (如 model_rate_limits / passive_usage_* 等)。 +func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return s.accountRepo.UpdateExtra(ctx, id, updates) +} + // BulkUpdateAccounts updates multiple accounts in one request. // It merges credentials/extra keys instead of overwriting the whole object. func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 92b0abca..6fd23c47 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -204,6 +204,30 @@ export async function refreshCredentials(id: number): Promise { return data } +/** + * Apply OAuth credentials after re-authorization. + * + * Unlike `update()`, this endpoint: + * - never overwrites the whole `extra` JSONB (merges incrementally instead), + * so persistent settings like `base_rpm`, `window_cost_limit`, `max_sessions`, + * `quota_*` and `privacy_mode` are preserved + * - clears the account error and invalidates the token cache server-side + */ +export async function applyOAuthCredentials( + id: number, + payload: { + type: 'oauth' | 'setup-token' + credentials: Record + extra?: Record + } +): Promise { + const { data } = await apiClient.post( + `/admin/accounts/${id}/apply-oauth-credentials`, + payload + ) + return data +} + /** * Get account usage statistics * @param id - Account ID @@ -665,6 +689,7 @@ export const accountsAPI = { toggleStatus, testAccount, refreshCredentials, + applyOAuthCredentials, getStats, clearError, getUsage, diff --git a/frontend/src/components/admin/account/ReAuthAccountModal.vue b/frontend/src/components/admin/account/ReAuthAccountModal.vue index 637d6011..b7178541 100644 --- a/frontend/src/components/admin/account/ReAuthAccountModal.vue +++ b/frontend/src/components/admin/account/ReAuthAccountModal.vue @@ -371,16 +371,12 @@ const handleExchangeCode = async () => { const extra = oauthClient.buildExtraInfo(tokenInfo) try { - // Update account with new credentials - await adminAPI.accounts.update(props.account.id, { - type: 'oauth', // OpenAI OAuth is always 'oauth' type + const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, { + type: 'oauth', credentials, extra }) - // Clear error status after successful re-authorization - const updatedAccount = await adminAPI.accounts.clearError(props.account.id) - appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) emit('reauthorized', updatedAccount) handleClose() @@ -476,16 +472,12 @@ const handleExchangeCode = async () => { const extra = claudeOAuth.buildExtraInfo(tokenInfo) - // Update account with new credentials and type - await adminAPI.accounts.update(props.account.id, { - type: addMethod.value, // Update type based on selected method - credentials: tokenInfo, + const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, { + type: addMethod.value as 'oauth' | 'setup-token', + credentials: tokenInfo as unknown as Record, extra }) - // Clear error status after successful re-authorization - const updatedAccount = await adminAPI.accounts.clearError(props.account.id) - appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) emit('reauthorized', updatedAccount) handleClose() @@ -519,16 +511,12 @@ const handleCookieAuth = async (sessionKey: string) => { const extra = claudeOAuth.buildExtraInfo(tokenInfo) - // Update account with new credentials and type - await adminAPI.accounts.update(props.account.id, { - type: addMethod.value, // Update type based on selected method - credentials: tokenInfo, + const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, { + type: addMethod.value as 'oauth' | 'setup-token', + credentials: tokenInfo as unknown as Record, extra }) - // Clear error status after successful re-authorization - const updatedAccount = await adminAPI.accounts.clearError(props.account.id) - appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess')) emit('reauthorized', updatedAccount) handleClose()