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:
DaydreamCoding
2026-05-26 19:46:08 +08:00
committed by QTom
co-authored by Claude Opus 4.7
parent 9ef144874a
commit 11fe7de926
6 changed files with 144 additions and 20 deletions
@@ -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) {