From d86e83259e22fe2711743a5e6f035bcc3ed86eb1 Mon Sep 17 00:00:00 2001 From: nslogx <8692455+nslogx@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:40:04 +0800 Subject: [PATCH 1/2] fix: allow five platform quota updates --- .../internal/handler/admin/setting_handler.go | 2 +- backend/internal/handler/admin/user_handler.go | 7 ++++--- .../admin/user_platform_quota_admin_test.go | 11 +++++++---- backend/internal/service/auth_service.go | 2 +- backend/internal/service/setting_service.go | 16 +++++++--------- .../setting_service_platform_quota_test.go | 16 ++++++++-------- 6 files changed, 28 insertions(+), 26 deletions(-) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 4f4807130e..e8a102c692 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -3800,7 +3800,7 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 { return nil } -// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all 12 slots. +// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots. func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool { for _, platform := range service.AllowedQuotaPlatforms { b := before[platform] diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index fb953057aa..b47310da1d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -3,6 +3,7 @@ package admin import ( "context" "errors" + "fmt" "log/slog" "math" "strconv" @@ -629,8 +630,8 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) { return } - if len(req.Quotas) > 4 { - response.BadRequest(c, "quotas length must be <= 4") + if len(req.Quotas) > len(service.AllowedQuotaPlatforms) { + response.BadRequest(c, fmt.Sprintf("quotas length must be <= %d", len(service.AllowedQuotaPlatforms))) return } seen := make(map[string]struct{}, len(req.Quotas)) @@ -749,7 +750,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) { // 失效 cache:对全部允许的 platform 统一 invalidate。 // Trade-off:精确失效(仅 req 涉及平台 + 被软删平台)需 upsert 前额外 ListByUser, - // 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 只有 4 个元素, + // 增加一次 DB 查询和逻辑复杂度。由于 AllowedQuotaPlatforms 数量很少, // 全量 invalidate 的额外开销可接受,且能可靠覆盖软删除场景。 if h.billingCache != nil { for _, p := range service.AllowedQuotaPlatforms { diff --git a/backend/internal/handler/admin/user_platform_quota_admin_test.go b/backend/internal/handler/admin/user_platform_quota_admin_test.go index 5689aedceb..0211480b82 100644 --- a/backend/internal/handler/admin/user_platform_quota_admin_test.go +++ b/backend/internal/handler/admin/user_platform_quota_admin_test.go @@ -98,7 +98,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) { body := `{"quotas":[ {"platform":"anthropic","daily_limit_usd":10.0,"weekly_limit_usd":null,"monthly_limit_usd":100.0}, - {"platform":"openai","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null} + {"platform":"openai","daily_limit_usd":80.0,"weekly_limit_usd":300.0,"monthly_limit_usd":null}, + {"platform":"gemini","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}, + {"platform":"antigravity","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null}, + {"platform":"grok","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null} ]}` c, w := putReq(t, body) h.UpdateUserPlatformQuotas(c) @@ -109,10 +112,10 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) { if len(repo.upsertCalls) != 1 { t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls)) } - if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 { + if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) { t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0]) } - // 缓存失效:请求中 2 个 platform + 软删除的 3 个 platform(gemini, antigravity, grok)= 5 次 + // 缓存失效:按全部允许平台统一失效。 if len(cache.deleteCalls) != 5 { t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls) } @@ -154,7 +157,7 @@ func TestUpdateUserPlatformQuotas_RejectsNegativeLimit(t *testing.T) { func TestUpdateUserPlatformQuotas_RejectsTooManyEntries(t *testing.T) { h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{}) body := `{"quotas":[ - {"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"anthropic"} + {"platform":"anthropic"},{"platform":"openai"},{"platform":"gemini"},{"platform":"antigravity"},{"platform":"grok"},{"platform":"anthropic"} ]}` c, w := putReq(t, body) h.UpdateUserPlatformQuotas(c) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 0be249d415..bc5b71854d 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -1659,7 +1659,7 @@ func resolvedTokenVersion(user *User) int64 { return user.TokenVersion ^ fingerprint } -// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(4 platform × 3 window)以 +// snapshotPlatformQuotaDefaults 把 plan.PlatformQuotas(platform × 3 window)以 // BulkInsertInitial 形式写入 user_platform_quotas 表。失败 fail-open(仅 warn log)。 func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID int64, plan *signupGrantPlan) error { if s.userPlatformQuotaRepo == nil || plan == nil || len(plan.PlatformQuotas) == 0 { diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 81415d457f..c2ee08fbd6 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -5202,17 +5202,15 @@ func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data)) } -// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回 4 platform x 3 window 的设置。 -// 永远返回包含全部 4 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 +// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。 +// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。 // // 使用单个 JSON key(default_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。 -// 容错语义:取值失败或 unmarshal 失败 → 返回补齐 4 key 的空 map(fail-open,注册不被阻断)。 +// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。 func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) { - out := map[string]*DefaultPlatformQuotaSetting{ - "anthropic": {}, - "openai": {}, - "gemini": {}, - "antigravity": {}, + out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms)) + for _, platform := range AllowedQuotaPlatforms { + out[platform] = &DefaultPlatformQuotaSetting{} } raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas) if err != nil || raw == "" { @@ -5228,7 +5226,7 @@ func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[stri out[platform] = v } } - return out, nil // 补齐 4 platform key,保持与旧实现一致的下游契约 + return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约 } // GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。 diff --git a/backend/internal/service/setting_service_platform_quota_test.go b/backend/internal/service/setting_service_platform_quota_test.go index 557cc5f194..a58c8274e7 100644 --- a/backend/internal/service/setting_service_platform_quota_test.go +++ b/backend/internal/service/setting_service_platform_quota_test.go @@ -60,18 +60,18 @@ func newSettingServiceForPlatformQuotaTest(seed map[string]string) *SettingServi return NewSettingService(repo, &config.Config{}) } -func TestGetDefaultPlatformQuotas_ReturnsFourPlatforms(t *testing.T) { +func TestGetDefaultPlatformQuotas_ReturnsAllowedPlatforms(t *testing.T) { zero := 0.0 svc := newSettingServiceForPlatformQuotaTest(map[string]string{ - // 新 JSON 格式:anthropic daily=10.5, openai monthly=0, gemini/antigravity 无配置 + // 新 JSON 格式:anthropic daily=10.5, openai monthly=0, 其他平台无配置 SettingKeyDefaultPlatformQuotas: `{"anthropic":{"daily":10.5},"openai":{"monthly":0}}`, }) got, err := svc.GetDefaultPlatformQuotas(context.Background()) if err != nil { t.Fatalf("unexpected error: %v", err) } - // 必须包含全部 4 个 platform key(补齐契约) - for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} { + // 必须包含全部允许 platform key(补齐契约) + for _, platform := range AllowedQuotaPlatforms { if _, ok := got[platform]; !ok { t.Errorf("missing platform key: %q", platform) } @@ -188,7 +188,7 @@ func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) { } // TestSystemPlatformQuotas_EmptyMapClearsAll 验证空 map 的整体替换语义: -// 写入 DefaultPlatformQuotas={} 后,GetDefaultPlatformQuotas 返回 4 个平台、所有字段均为 nil, +// 写入 DefaultPlatformQuotas={} 后,GetDefaultPlatformQuotas 返回全部允许平台、所有字段均为 nil, // 明确文档化"空 map = 清空全部配额"是有意为之的 whole-replace 语义。 func TestSystemPlatformQuotas_EmptyMapClearsAll(t *testing.T) { svc := newSettingServiceForPlatformQuotaTest(nil) @@ -215,10 +215,10 @@ func TestSystemPlatformQuotas_EmptyMapClearsAll(t *testing.T) { if err != nil { t.Fatal(err) } - // 4 个 key 仍然存在(补齐契约) - for _, p := range []string{"anthropic", "openai", "gemini", "antigravity"} { + // 全部允许平台 key 仍然存在(补齐契约) + for _, p := range AllowedQuotaPlatforms { if _, ok := got[p]; !ok { - t.Errorf("4-key contract violated after empty write: missing %q", p) + t.Errorf("allowed-platform contract violated after empty write: missing %q", p) } } // 所有字段 nil(全部已清空) From 6c46c2cb7d2c3b56153e0e2c4c2751f2f13b43ad Mon Sep 17 00:00:00 2001 From: nslogx <8692455+nslogx@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:57:22 +0800 Subject: [PATCH 2/2] test: update platform quota contract for grok --- backend/internal/server/api_contract_test.go | 4 ++-- .../service/setting_service_platform_quota_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index e925e54121..9c2f340f05 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -802,7 +802,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1049,7 +1049,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, diff --git a/backend/internal/service/setting_service_platform_quota_test.go b/backend/internal/service/setting_service_platform_quota_test.go index a58c8274e7..cde03b462c 100644 --- a/backend/internal/service/setting_service_platform_quota_test.go +++ b/backend/internal/service/setting_service_platform_quota_test.go @@ -152,7 +152,7 @@ func TestGetAuthSourcePlatformQuotas_AllNegativeOrEmpty_NoEntry(t *testing.T) { } // TestSystemPlatformQuotas_WriteReadRoundTrip 验证系统层 platform quota 经 buildSystemSettingsUpdates(写) -// 再由 GetDefaultPlatformQuotas(读)正确往返——覆盖真实 write→read 路径,锁住 4-key 补齐契约。 +// 再由 GetDefaultPlatformQuotas(读)正确往返,覆盖真实 write→read 路径并锁住平台补齐契约。 func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) { svc := newSettingServiceForPlatformQuotaTest(nil) ctx := context.Background() @@ -171,10 +171,10 @@ func TestSystemPlatformQuotas_WriteReadRoundTrip(t *testing.T) { if err != nil { t.Fatal(err) } - // 4-key 补齐契约:无论写了几个 platform,读回必须含全部 4 个 - for _, p := range []string{"anthropic", "openai", "gemini", "antigravity"} { + // 平台补齐契约:无论写了几个 platform,读回必须含全部允许平台 + for _, p := range AllowedQuotaPlatforms { if _, ok := got[p]; !ok { - t.Errorf("4-key contract violated: missing platform %q", p) + t.Errorf("allowed-platform contract violated: missing platform %q", p) } } // 写入值正确往返