sub2api/backend/internal/service/auth_service_platform_quota_test.go
DaydreamCoding f7f5e33830 feat(quota): user×platform 配额 DB 写聚合 flusher
Redis 同步权威 + DB 镜像,不在进程内维护 delta:
- 写入点 HasUserPlatformQuotaLimit 守卫:无 limit 跳过 Redis 写与持久化
- 累加 usage 的 Lua 在 flusher_enabled 时 SADD 脏集 billing:upq:dirty
- UserPlatformQuotaUsageFlusher 定时 SPOP 脏集 → 批量 HGETALL 读当前窗口 usage 快照
  → BatchSnapshotUsage 绝对值 UPSERT 覆盖 DB(去 SELECT FOR UPDATE 行锁)
  → 失败 SADD 回 / FK(23503)整批丢弃
- flusher 单批 clamp 到 ≤6000,保证一次 flush 只生成一条 UPSERT(单事务原子)
- flusher_enabled 默认 false(降级=旧异步直写 DB)

效果:DB 写连接从 O(QPS) 收敛到 O(副本)。
循环依赖:service 层独立 Snapshot/FK 类型,repository adapter 转换 + %w 映射 FK error。
admin reset/upsert 后失效 cache(脏残留被 flusher 当 MISS 跳过)。

健壮性与可观测性:
- flusher_enabled=false 时 Start 不注册定时器;flush_interval_ms 非法回退 2s
- Readd 回填失败单独计 dirty_lost(不再误记 dirty_readd)并 ALERT;脏集 Readd 补兜底 TTL
- 单 tick 达 max batches 上限仍有积压时记 log
- admin 失效 cache 失败升级为 ALERT(提示 enforcement 可能延迟至 sentinel TTL)
- BatchGet 单条命令失败 / usage 字段损坏均记 log,避免静默以 0 覆写 DB

三态 go vet + 单测/集成测全绿。
已知取舍(默认 flusher_enabled=false 不触发):
- FK 整批丢弃牵连同批正常 key(活跃 key 靠下次 SADD+绝对值快照自愈;Redis 仍权威)
- admin reset/upsert 直写 DB 与 flusher 异步刷存在覆盖竞态:flusher 持旧快照在途时可能覆盖
  admin 刚写值(limit 列不受影响;usage 有 preflight windowExpired 兜底;低频)。彻底消除需 version OCC。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:19:15 +08:00

162 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build unit
package service
import (
"context"
"fmt"
"testing"
"time"
)
// fakeInsertRecorder 记录 BulkInsertInitial 调用,实现 UserPlatformQuotaRepository port。
type fakeInsertRecorder struct {
records []UserPlatformQuotaRecord
err error
}
func (f *fakeInsertRecorder) GetByUserPlatform(_ context.Context, _ int64, _ string) (*UserPlatformQuotaRecord, error) {
return nil, nil
}
func (f *fakeInsertRecorder) BulkInsertInitial(_ context.Context, recs []UserPlatformQuotaRecord) error {
if f.err != nil {
return f.err
}
f.records = append(f.records, recs...)
return nil
}
func (f *fakeInsertRecorder) IncrementUsageWithReset(_ context.Context, _ int64, _ string, _ float64, _ time.Time) error {
return nil
}
func (f *fakeInsertRecorder) ListByUser(_ context.Context, _ int64) ([]UserPlatformQuotaRecord, error) {
return nil, nil
}
func (f *fakeInsertRecorder) UpsertForUser(_ context.Context, _ int64, _ []UserPlatformQuotaRecord) error {
return nil
}
func (f *fakeInsertRecorder) ResetExpiredWindow(_ context.Context, _ int64, _ string, _ string, _ time.Time) error {
return nil
}
func (f *fakeInsertRecorder) BatchSnapshotUsage(_ context.Context, _ []UserPlatformQuotaSnapshot, _ time.Time) error {
return nil
}
func TestSnapshotPlatformQuotaDefaults_PassesToRepoBulkInsert(t *testing.T) {
fakeRepo := &fakeInsertRecorder{}
s := &AuthService{userPlatformQuotaRepo: fakeRepo}
five := 5.0
plan := &signupGrantPlan{
PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
"anthropic": {DailyLimitUSD: &five},
"openai": {},
"gemini": {},
"antigravity": {},
},
}
if err := s.snapshotPlatformQuotaDefaults(context.Background(), 999, plan); err != nil {
t.Fatal(err)
}
if len(fakeRepo.records) != 4 {
t.Errorf("expected 4 records, got %d", len(fakeRepo.records))
}
found := false
for _, r := range fakeRepo.records {
if r.UserID == 999 && r.Platform == "anthropic" && r.DailyLimitUSD != nil && *r.DailyLimitUSD == 5 {
found = true
}
}
if !found {
t.Error("anthropic daily = 5 not snapshotted")
}
}
func TestSnapshotPlatformQuotaDefaults_NilPlanIsNoop(t *testing.T) {
fakeRepo := &fakeInsertRecorder{}
s := &AuthService{userPlatformQuotaRepo: fakeRepo}
if err := s.snapshotPlatformQuotaDefaults(context.Background(), 1, nil); err != nil {
t.Errorf("nil plan should be noop, got %v", err)
}
if len(fakeRepo.records) != 0 {
t.Errorf("expected no records, got %d", len(fakeRepo.records))
}
}
func TestSnapshotPlatformQuotaDefaults_RepoErrorFailsOpen(t *testing.T) {
fakeRepo := &fakeInsertRecorder{err: fmt.Errorf("db down")}
s := &AuthService{userPlatformQuotaRepo: fakeRepo}
five := 5.0
plan := &signupGrantPlan{
PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{
"anthropic": {DailyLimitUSD: &five},
},
}
if err := s.snapshotPlatformQuotaDefaults(context.Background(), 1, plan); err != nil {
t.Errorf("fail-open: expected nil even on repo error, got %v", err)
}
}
func TestSnapshotPlatformQuotaDefaults_NilRepoIsNoop(t *testing.T) {
s := &AuthService{userPlatformQuotaRepo: nil}
five := 5.0
plan := &signupGrantPlan{
PlatformQuotas: map[string]*DefaultPlatformQuotaSetting{"a": {DailyLimitUSD: &five}},
}
if err := s.snapshotPlatformQuotaDefaults(context.Background(), 1, plan); err != nil {
t.Errorf("nil repo should be noop, got %v", err)
}
}
// resolveSignupGrantPlan 测试:依赖完整的 AuthService 构造,需要 SettingService含 settingRepoStub
// settingRepoStub 已在 auth_service_register_test.go 中定义,同 package 可直接使用。
func TestResolveSignupGrantPlan_GlobalQuotaLoadedBeforeAuthSource(t *testing.T) {
// 全局 quota JSON key新格式
settings := map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyDefaultPlatformQuotas: `{
"anthropic": {"daily": 10, "weekly": 50, "monthly": 200},
"openai": {"daily": 5, "weekly": 25, "monthly": 100},
"gemini": {"daily": 5, "weekly": 25, "monthly": 100},
"antigravity": {"daily": 5, "weekly": 25, "monthly": 100}
}`,
}
svc := newAuthService(nil, settings, nil, nil)
plan := svc.resolveSignupGrantPlan(context.Background(), "email")
if plan.PlatformQuotas == nil {
t.Fatal("expected PlatformQuotas to be non-nil after loading global quota KVs")
}
q := plan.PlatformQuotas["anthropic"]
if q == nil {
t.Fatal("expected anthropic quota to be set")
}
if q.DailyLimitUSD == nil || *q.DailyLimitUSD != 10 {
t.Errorf("expected anthropic daily=10, got %v", q.DailyLimitUSD)
}
}
// TestResolveSignupGrantPlan_DisabledAuthSourceStillCarriesGlobalQuota 验证 P1 约束:
// !enabled 早退路径仍携带全局 quotaGetDefaultPlatformQuotas 在 ResolveAuthSourceGrantSettings 之前)。
func TestResolveSignupGrantPlan_DisabledAuthSourceStillCarriesGlobalQuota(t *testing.T) {
settings := map[string]string{
SettingKeyRegistrationEnabled: "true",
// auth source 不配置(=> !enabled 路径)
SettingKeyDefaultPlatformQuotas: `{"anthropic": {"daily": 10, "weekly": 50, "monthly": 200}}`,
}
svc := newAuthService(nil, settings, nil, nil)
plan := svc.resolveSignupGrantPlan(context.Background(), "email")
// !enabled 路径plan.PlatformQuotas 应已含全局层(不是 nil
if plan.PlatformQuotas == nil {
t.Fatal("P1 violated: PlatformQuotas is nil even with global quota KVs set")
}
// P1 核心断言disabled auth source 路径不能丢失全局 quota
if _, ok := plan.PlatformQuotas["anthropic"]; !ok {
t.Error("P1 violated: disabled auth source path dropped global platform quota")
}
}