From 3e60c3b86b4849bdd283f92cb88b5a7c1a79d456 Mon Sep 17 00:00:00 2001 From: Ritel-T Date: Wed, 2 Sep 2026 18:52:05 +0900 Subject: [PATCH] fix: add compact admin account list DTO --- .../internal/handler/admin/account_handler.go | 81 +++++++++++++--- .../admin/account_handler_list_test.go | 92 ++++++++++++++++++ backend/internal/handler/dto/mappers.go | 41 ++++++++ backend/internal/handler/dto/types.go | 96 +++++++++++++++++++ 4 files changed, 297 insertions(+), 13 deletions(-) diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 599a04a34e..c3978fb76d 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -199,6 +199,19 @@ type AccountWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +// AccountListItemWithConcurrency is the compact account-list envelope used +// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account, +// so groups/account_groups never appear in the list payload. +type AccountListItemWithConcurrency struct { + *dto.AccountListItem + CurrentConcurrency int `json:"current_concurrency"` + SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` + SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` + CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` + ActiveSessions *int `json:"active_sessions,omitempty"` + CurrentRPM *int `json:"current_rpm,omitempty"` +} + type AccountSchedulerScore struct { BaseScore float64 `json:"base_score"` StickyScore float64 `json:"sticky_score"` @@ -223,6 +236,17 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d return out } +func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account { + out := dto.AccountFromServiceShallow(account) + if out != nil && account != nil { + out.Proxy = dto.ProxyFromService(account.Proxy) + } + if h != nil && h.ollamaCloudUsage != nil && out != nil { + h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage) + } + return out +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: h.accountResponseFromService(account), @@ -650,8 +674,12 @@ func (h *AccountHandler) List(c *gin.Context) { result := make([]AccountWithConcurrency, len(accounts)) for i := range accounts { acc := &accounts[i] + accountResponse := h.accountResponseFromService(acc) + if lite { + accountResponse = h.accountListResponseFromService(acc) + } item := AccountWithConcurrency{ - Account: h.accountResponseFromService(acc), + Account: accountResponse, CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], @@ -683,7 +711,34 @@ func (h *AccountHandler) List(c *gin.Context) { h.enrichShadowParents(c.Request.Context(), result) - etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite) + if lite { + compact := make([]AccountListItemWithConcurrency, len(result)) + for i := range result { + item := result[i] + compact[i] = AccountListItemWithConcurrency{ + AccountListItem: dto.AccountListItemFromAccount(item.Account), + CurrentConcurrency: item.CurrentConcurrency, + SchedulerScore: item.SchedulerScore, + SchedulerScores: item.SchedulerScores, + CurrentWindowCost: item.CurrentWindowCost, + ActiveSessions: item.ActiveSessions, + CurrentRPM: item.CurrentRPM, + } + } + etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true) + if etag != "" { + c.Header("ETag", etag) + c.Header("Vary", "If-None-Match") + if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } + } + response.Paginated(c, compact, total, page, pageSize) + return + } + + etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false) if etag != "" { c.Header("ETag", etag) c.Header("Vary", "If-None-Match") @@ -696,23 +751,23 @@ func (h *AccountHandler) List(c *gin.Context) { response.Paginated(c, result, total, page, pageSize) } -func buildAccountsListETag( - items []AccountWithConcurrency, +func buildAccountsListETag[T any]( + items []T, total int64, page, pageSize int, platform, accountType, status, search string, lite bool, ) string { payload := struct { - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Platform string `json:"platform"` - AccountType string `json:"type"` - Status string `json:"status"` - Search string `json:"search"` - Lite bool `json:"lite"` - Items []AccountWithConcurrency `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Platform string `json:"platform"` + AccountType string `json:"type"` + Status string `json:"status"` + Search string `json:"search"` + Lite bool `json:"lite"` + Items []T `json:"items"` }{ Total: total, Page: page, diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 6a10bb4dcc..d92a2e1328 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -1,9 +1,12 @@ package admin import ( + "bytes" + "compress/gzip" "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -13,6 +16,95 @@ import ( "github.com/stretchr/testify/require" ) +func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(77) + adminSvc.accounts = []service.Account{{ + ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)}, + Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive, + Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}, + AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}}, + CreatedAt: now, UpdatedAt: now, + }} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.NotEmpty(t, rec.Header().Get("ETag")) + + var litePayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload)) + require.Len(t, litePayload.Data.Items, 1) + liteItem := litePayload.Data.Items[0] + require.Equal(t, float64(501), liteItem["id"]) + require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"]) + require.Equal(t, true, liteItem["schedulable"]) + require.NotContains(t, liteItem, "groups") + require.NotContains(t, liteItem, "account_groups") + require.NotContains(t, liteItem, "access_token") + + // The ETag must represent the same compact body and return 304 on refresh. + rec304 := httptest.NewRecorder() + req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + req304.Header.Set("If-None-Match", rec.Header().Get("ETag")) + router.ServeHTTP(rec304, req304) + require.Equal(t, http.StatusNotModified, rec304.Code) + + // Omitting lite preserves the legacy full response shape. + recFull := httptest.NewRecorder() + reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil) + router.ServeHTTP(recFull, reqFull) + require.Equal(t, http.StatusOK, recFull.Code) + var fullPayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload)) + require.Contains(t, fullPayload.Data.Items[0], "groups") + require.Contains(t, fullPayload.Data.Items[0], "account_groups") +} + +func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + accounts := make([]service.Account, 20) + for i := range accounts { + id := int64(600 + i) + groupID := int64(800 + i) + accounts[i] = service.Account{ + ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true, + Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}, + AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}}, + CreatedAt: now, UpdatedAt: now, + } + } + adminSvc.accounts = accounts + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Less(t, rec.Body.Len(), 80*1024) + + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + _, err := zw.Write(rec.Body.Bytes()) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.Less(t, compressed.Len(), 15*1024) +} + func setupAccountListRouter() (*gin.Engine, *stubAdminService) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..46ccc1d666 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -447,6 +447,47 @@ func AccountFromService(a *service.Account) *Account { return out } +// AccountListItemFromAccount projects a full account response into the +// compact shape used by the paginated admin account list. Keeping this +// projection separate from Account preserves the existing detail API. +func AccountListItemFromAccount(a *Account) *AccountListItem { + if a == nil { + return nil + } + return &AccountListItem{ + ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, + Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, + OllamaCloudUsage: a.OllamaCloudUsage, + ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, + Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, + AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, + Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt, + OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil, + TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart, + SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus, + WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve, + MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM, + RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode, + EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID, + EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled, + CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled, + CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed, + QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit, + QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode, + QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode, + QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour, + QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt, + QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled, + QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled, + QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled, + QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID, + QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType, + ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt, + ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs, + } +} + func timeToUnixSeconds(value *time.Time) *int64 { if value == nil { return nil diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..34ab54e9de 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -322,6 +322,102 @@ type Account struct { Groups []*Group `json:"groups,omitempty"` } +// AccountListItem is the compact representation returned by the admin account +// list when the lite query parameter is enabled. It contains the fields used +// by the account table and runtime indicators, but intentionally omits the +// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id +// for the complete Account DTO when editing or inspecting an account. +type AccountListItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + + Credentials map[string]any `json:"credentials,omitempty"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra,omitempty"` + OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + + ProxyID *int64 `json:"proxy_id"` + ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` + ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Schedulable bool `json:"schedulable"` + + RateLimitedAt *time.Time `json:"rate_limited_at"` + RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` + OverloadUntil *time.Time `json:"overload_until"` + + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"` + TempUnschedulableReason string `json:"temp_unschedulable_reason"` + + SessionWindowStart *time.Time `json:"session_window_start"` + SessionWindowEnd *time.Time `json:"session_window_end"` + SessionWindowStatus string `json:"session_window_status"` + + WindowCostLimit *float64 `json:"window_cost_limit,omitempty"` + WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"` + MaxSessions *int `json:"max_sessions,omitempty"` + SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"` + BaseRPM *int `json:"base_rpm,omitempty"` + RPMStrategy *string `json:"rpm_strategy,omitempty"` + RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"` + UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"` + EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"` + TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"` + EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"` + CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"` + CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` + CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"` + CustomBaseURL *string `json:"custom_base_url,omitempty"` + + QuotaLimit *float64 `json:"quota_limit,omitempty"` + QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"` + QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"` + QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` + QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"` + + QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"` + QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"` + QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"` + QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"` + QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"` + QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"` + QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"` + QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"` + + QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"` + QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"` + QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"` + QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"` + QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"` + QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"` + + ParentAccountID *int64 `json:"parent_account_id,omitempty"` + QuotaDimension string `json:"quota_dimension,omitempty"` + ParentEmail string `json:"parent_email,omitempty"` + ParentPlanType string `json:"parent_plan_type,omitempty"` + ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"` + ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"` + ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"` + + Proxy *Proxy `json:"proxy,omitempty"` + GroupIDs []int64 `json:"group_ids,omitempty"` +} + type AccountGroup struct { AccountID int64 `json:"account_id"` GroupID int64 `json:"group_id"`