mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #3546 from nslogx/fix/platform-quota-five-platforms
fix: allow five platform quota updates
This commit is contained in:
@@ -3812,7 +3812,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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -1050,7 +1050,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5228,17 +5228,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 == "" {
|
||||
@@ -5254,7 +5252,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 语义)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
// 写入值正确往返
|
||||
@@ -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(全部已清空)
|
||||
|
||||
Reference in New Issue
Block a user