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))
|
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
|
// GetStats handles getting account statistics
|
||||||
// GET /api/v1/admin/accounts/:id/stats
|
// GET /api/v1/admin/accounts/:id/stats
|
||||||
func (h *AccountHandler) GetStats(c *gin.Context) {
|
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
|
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 {
|
func (s *stubAdminService) DeleteAccount(ctx context.Context, id int64) error {
|
||||||
return nil
|
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/test", h.Admin.Account.Test)
|
||||||
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
|
accounts.POST("/:id/recover-state", h.Admin.Account.RecoverState)
|
||||||
accounts.POST("/:id/refresh", h.Admin.Account.Refresh)
|
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/set-privacy", h.Admin.Account.SetPrivacy)
|
||||||
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
|
accounts.POST("/:id/refresh-tier", h.Admin.Account.RefreshTier)
|
||||||
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
|
accounts.GET("/:id/stats", h.Admin.Account.GetStats)
|
||||||
|
|||||||
@ -72,6 +72,9 @@ type AdminService interface {
|
|||||||
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error)
|
||||||
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error)
|
||||||
UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*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
|
DeleteAccount(ctx context.Context, id int64) error
|
||||||
RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error)
|
RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error)
|
||||||
ClearAccountError(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
|
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.
|
// BulkUpdateAccounts updates multiple accounts in one request.
|
||||||
// It merges credentials/extra keys instead of overwriting the whole object.
|
// It merges credentials/extra keys instead of overwriting the whole object.
|
||||||
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
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
|
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
|
* Get account usage statistics
|
||||||
* @param id - Account ID
|
* @param id - Account ID
|
||||||
@ -665,6 +689,7 @@ export const accountsAPI = {
|
|||||||
toggleStatus,
|
toggleStatus,
|
||||||
testAccount,
|
testAccount,
|
||||||
refreshCredentials,
|
refreshCredentials,
|
||||||
|
applyOAuthCredentials,
|
||||||
getStats,
|
getStats,
|
||||||
clearError,
|
clearError,
|
||||||
getUsage,
|
getUsage,
|
||||||
|
|||||||
@ -371,16 +371,12 @@ const handleExchangeCode = async () => {
|
|||||||
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
const extra = oauthClient.buildExtraInfo(tokenInfo)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update account with new credentials
|
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
|
||||||
await adminAPI.accounts.update(props.account.id, {
|
type: 'oauth',
|
||||||
type: 'oauth', // OpenAI OAuth is always 'oauth' type
|
|
||||||
credentials,
|
credentials,
|
||||||
extra
|
extra
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear error status after successful re-authorization
|
|
||||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
|
||||||
|
|
||||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||||
emit('reauthorized', updatedAccount)
|
emit('reauthorized', updatedAccount)
|
||||||
handleClose()
|
handleClose()
|
||||||
@ -476,16 +472,12 @@ const handleExchangeCode = async () => {
|
|||||||
|
|
||||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||||
|
|
||||||
// Update account with new credentials and type
|
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
|
||||||
await adminAPI.accounts.update(props.account.id, {
|
type: addMethod.value as 'oauth' | 'setup-token',
|
||||||
type: addMethod.value, // Update type based on selected method
|
credentials: tokenInfo as unknown as Record<string, unknown>,
|
||||||
credentials: tokenInfo,
|
|
||||||
extra
|
extra
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear error status after successful re-authorization
|
|
||||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
|
||||||
|
|
||||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||||
emit('reauthorized', updatedAccount)
|
emit('reauthorized', updatedAccount)
|
||||||
handleClose()
|
handleClose()
|
||||||
@ -519,16 +511,12 @@ const handleCookieAuth = async (sessionKey: string) => {
|
|||||||
|
|
||||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||||
|
|
||||||
// Update account with new credentials and type
|
const updatedAccount = await adminAPI.accounts.applyOAuthCredentials(props.account.id, {
|
||||||
await adminAPI.accounts.update(props.account.id, {
|
type: addMethod.value as 'oauth' | 'setup-token',
|
||||||
type: addMethod.value, // Update type based on selected method
|
credentials: tokenInfo as unknown as Record<string, unknown>,
|
||||||
credentials: tokenInfo,
|
|
||||||
extra
|
extra
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clear error status after successful re-authorization
|
|
||||||
const updatedAccount = await adminAPI.accounts.clearError(props.account.id)
|
|
||||||
|
|
||||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||||
emit('reauthorized', updatedAccount)
|
emit('reauthorized', updatedAccount)
|
||||||
handleClose()
|
handleClose()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user