Merge pull request #2796 from DaydreamCoding/fix/account-reauth-keep-extra

fix(account): 重新授权不再清空 Extra 配置
This commit is contained in:
Wesley Liddick 2026-05-26 20:06:48 +08:00 committed by GitHub
commit 4a5c5367cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 144 additions and 20 deletions

View File

@ -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
}
// 增量合并 ExtraJSONB 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) {

View File

@ -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
}

View File

@ -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)

View File

@ -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) {

View File

@ -204,6 +204,30 @@ export async function refreshCredentials(id: number): Promise<Account> {
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<string, unknown>
extra?: Record<string, unknown>
}
): Promise<Account> {
const { data } = await apiClient.post<Account>(
`/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,

View File

@ -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<string, unknown>,
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<string, unknown>,
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()