fix(account): 重新授权不再清空 Extra 配置
Claude / OpenAI 账号重新授权走通用 PUT /accounts/:id 时,后端 UpdateAccount 会全量覆盖 account.Extra(仅保留 5 个 quota 用量键), 导致 base_rpm / window_cost_limit / window_cost_sticky_reserve / max_sessions / quota_* / privacy_mode 等持久化配置全部丢失。 新增专用接口 POST /accounts/:id/apply-oauth-credentials,沿用 现有 /refresh 路径模式:Credentials-only update + Extra JSONB key 级合并(UpdateAccountExtra) + ClearError + InvalidateToken。 作用域:Claude OAuth / Claude Cookie auth / OpenAI OAuth 三个 调用点。Gemini / Antigravity 现有路径本就不传 extra,保持不变。 顺带修复:旧重新授权路径未调用 InvalidateToken,导致重新授权后 首请求可能仍用缓存中的旧 token 而立即 401。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ef144874a
commit
11fe7de926
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user