feat(server): 免费版账户级分钟卡控 + 累加式看广告加时(#21 后端)
免费版此前形同虚设:ConnectNode 每次连接发固定 daily_minutes×1min TTL、
从不扣减已用,重连即崭新 10 分钟,日额度从未强制。改为账户级(全设备共享)
真卡控 + 累加式看广告加时:
- migration 000020: usage_daily 加 ad_bonus_minutes(sqlite+mysql)。
当日额度 = plans.daily_minutes + ad_bonus_minutes;剩余 = 额度 − minutes_used。
- usage.store.AddAdBonusMinutes: 事务内累加封顶(替代布尔 MarkAdUnlocked),
返回新总额+本次实际加时;GetDay/GetUsageRange 补读 ad_bonus_minutes。
- usage.service.UnlockAd: verify+nonce 后 +adBonusPerAd(10) 封顶 adDailyBonusCeiling(120),
返回 granted+remaining;TodaySummary 加 MinutesCap/AdBonusMinutes。
- usage.ads DevVerifier: 放行式占位校验(nonce 防重放仍生效),接真 AdMob 前
让看广告加时流程可端到端跑通;main.go 按 ADS_DEV_MODE/默认装配。
- ads/unlock: 204 → 200 返回 {granted_minutes, minutes_remaining}。
- ConnectNode 免费门: 读 AccountDayMinutes(used,bonus) → remaining≤0 拒 QUOTA_EXHAUSTED,
否则 TTL=remaining(凭证到点硬切断兜底)。nodes.store 加 AccountDayMinutes。
- /me: 补 ad_bonus_minutes,quota_today_min 分母改 daily+bonus,加 quota_cap_min。
- quota.CheckFreeConnect 同步累加语义(免费基础 10 分钟不再需先看广告)。
- openapi + 契约/迁移版本/集成测试同步;新增 SQLite AddAdBonusMinutes 单测。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -297,22 +297,23 @@ func TestIntAggregateUnknownDPUUIDDropped(t *testing.T) {
|
||||
// Quota (CheckFreeConnect)
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestIntQuotaFreeNotUnlocked(t *testing.T) {
|
||||
func TestIntQuotaFreeBaseNoAd(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
svc := usage.NewService(usage.NewStore(db), nil, fakeVerifier{ok: true}, time.Hour)
|
||||
|
||||
uid := createUser(t, db, "dp-q1") // no subscription → free plan
|
||||
|
||||
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||
if apiErr == nil || apiErr.Code != "AD_NOT_UNLOCKED" {
|
||||
t.Fatalf("expected AD_NOT_UNLOCKED, got %v", apiErr)
|
||||
// 免费版基础 10 分钟无需看广告即可连接(广告只用于加时)。
|
||||
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("free base connect should succeed without ad, got %v", apiErr)
|
||||
}
|
||||
if apiErr.MessageZH == "" || apiErr.MessageEn == "" {
|
||||
t.Error("expected bilingual error messages")
|
||||
if rem != 10 {
|
||||
t.Errorf("remaining=%d, want 10 (base free minutes)", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) {
|
||||
func TestIntQuotaFreeAdBonusAccumulates(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
store := usage.NewStore(db)
|
||||
agg := usage.NewAggregator(store, nil, time.Minute, time.Minute)
|
||||
@@ -320,23 +321,26 @@ func TestIntQuotaFreeUnlockedRemainingDecrements(t *testing.T) {
|
||||
|
||||
uid := createUser(t, db, "dp-q2")
|
||||
|
||||
if _, err := store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
|
||||
// Base free minutes (no ad).
|
||||
rem, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("unexpected err: %v", apiErr)
|
||||
}
|
||||
if rem != 10 {
|
||||
t.Errorf("remaining=%d, want 10", rem)
|
||||
if apiErr != nil || rem != 10 {
|
||||
t.Fatalf("base: rem=%d err=%v, want 10", rem, apiErr)
|
||||
}
|
||||
|
||||
// Consume 4 minutes.
|
||||
// Watch an ad → +10 bonus → allowance 20.
|
||||
if _, _, err := store.AddAdBonusMinutes(context.Background(), uid, time.Now().UTC(), 10, 120); err != nil {
|
||||
t.Fatalf("add bonus: %v", err)
|
||||
}
|
||||
rem, _ = svc.CheckFreeConnect(context.Background(), uid)
|
||||
if rem != 20 {
|
||||
t.Errorf("after ad: remaining=%d, want 20", rem)
|
||||
}
|
||||
|
||||
// Consume 4 minutes → 16.
|
||||
agg.ReportUsage(context.Background(), usage.Report{DPUUID: "dp-q2", Minutes: 4})
|
||||
rem, _ = svc.CheckFreeConnect(context.Background(), uid)
|
||||
if rem != 6 {
|
||||
t.Errorf("after 4 min: remaining=%d, want 6", rem)
|
||||
if rem != 16 {
|
||||
t.Errorf("after 4 min: remaining=%d, want 16", rem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,7 +350,7 @@ func TestIntQuotaFreeExhausted(t *testing.T) {
|
||||
svc := usage.NewService(store, nil, fakeVerifier{ok: true}, time.Hour)
|
||||
|
||||
uid := createUser(t, db, "dp-q3")
|
||||
store.MarkAdUnlocked(context.Background(), uid, time.Now().UTC())
|
||||
// Base 10 minutes fully consumed, no ad bonus → exhausted.
|
||||
store.AggregateUsage(context.Background(), uid, time.Now().UTC(), 0, 0, 10)
|
||||
|
||||
_, apiErr := svc.CheckFreeConnect(context.Background(), uid)
|
||||
@@ -386,7 +390,7 @@ func TestIntAdsUnlockForgedRejected(t *testing.T) {
|
||||
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: false}, time.Hour)
|
||||
|
||||
uid := createUser(t, db, "dp-ad1")
|
||||
_, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token")
|
||||
_, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "forged-token")
|
||||
if apiErr == nil || apiErr.Code != "AD_VERIFY_FAILED" {
|
||||
t.Fatalf("expected AD_VERIFY_FAILED, got %v", apiErr)
|
||||
}
|
||||
@@ -398,33 +402,35 @@ func TestIntAdsUnlockReplayRejected(t *testing.T) {
|
||||
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
|
||||
|
||||
uid := createUser(t, db, "dp-ad2")
|
||||
already, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
||||
if apiErr != nil || already {
|
||||
t.Fatalf("first unlock: already=%v err=%v", already, apiErr)
|
||||
granted, _, apiErr := svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
||||
if apiErr != nil || granted != 10 {
|
||||
t.Fatalf("first unlock: granted=%d err=%v, want granted=10", granted, apiErr)
|
||||
}
|
||||
// Same token again → replay.
|
||||
_, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
||||
_, _, apiErr = svc.UnlockAd(context.Background(), uid, "device-1", "token-xyz")
|
||||
if apiErr == nil || apiErr.Code != "AD_TOKEN_REPLAY" {
|
||||
t.Fatalf("expected AD_TOKEN_REPLAY, got %v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntAdsUnlockSecondTimeIdempotent(t *testing.T) {
|
||||
func TestIntAdsUnlockAccumulates(t *testing.T) {
|
||||
db := setupMySQL(t)
|
||||
rdb := setupRedis(t)
|
||||
svc := usage.NewService(usage.NewStore(db), rdb, fakeVerifier{ok: true}, time.Hour)
|
||||
|
||||
uid := createUser(t, db, "dp-ad3")
|
||||
if _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1"); apiErr != nil {
|
||||
t.Fatalf("first unlock: %v", apiErr)
|
||||
// 累加式:每次不同 token 各加 10 分钟,而非幂等。
|
||||
g1, _, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-1")
|
||||
if apiErr != nil || g1 != 10 {
|
||||
t.Fatalf("first unlock: granted=%d err=%v, want 10", g1, apiErr)
|
||||
}
|
||||
// Different (valid) token, same day → idempotent success.
|
||||
already, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2")
|
||||
if apiErr != nil {
|
||||
t.Fatalf("second unlock err: %v", apiErr)
|
||||
g2, rem, apiErr := svc.UnlockAd(context.Background(), uid, "d", "tok-2")
|
||||
if apiErr != nil || g2 != 10 {
|
||||
t.Fatalf("second unlock: granted=%d err=%v, want 10", g2, apiErr)
|
||||
}
|
||||
if !already {
|
||||
t.Error("expected already-unlocked idempotent success")
|
||||
// allowance = 10 base + 20 bonus, used 0 → remaining 30.
|
||||
if rem != 30 {
|
||||
t.Errorf("remaining after 2 ads=%d, want 30", rem)
|
||||
}
|
||||
|
||||
// Exactly one unlock timestamp.
|
||||
@@ -505,17 +511,22 @@ func TestIntTodaySummary(t *testing.T) {
|
||||
store := usage.NewStore(db)
|
||||
svc := usage.NewService(store, nil, nil, time.Hour)
|
||||
|
||||
// Free user: limited + ad flag.
|
||||
// Free user: base 10 + ad bonus 10 = cap 20; used 3 → remaining 17.
|
||||
uFree := createUser(t, db, "dp-sf")
|
||||
store.MarkAdUnlocked(context.Background(), uFree, time.Now().UTC())
|
||||
if _, _, err := store.AddAdBonusMinutes(context.Background(), uFree, time.Now().UTC(), 10, 120); err != nil {
|
||||
t.Fatalf("add bonus: %v", err)
|
||||
}
|
||||
store.AggregateUsage(context.Background(), uFree, time.Now().UTC(), 0, 0, 3)
|
||||
|
||||
sum, apiErr := svc.TodaySummary(context.Background(), uFree)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("summary: %v", apiErr)
|
||||
}
|
||||
if sum.MinutesUsed != 3 || sum.MinutesRemaining == nil || *sum.MinutesRemaining != 7 || !sum.AdUnlocked {
|
||||
t.Errorf("free summary wrong: %+v (remaining=%v)", sum, sum.MinutesRemaining)
|
||||
if sum.MinutesUsed != 3 ||
|
||||
sum.MinutesCap == nil || *sum.MinutesCap != 20 ||
|
||||
sum.MinutesRemaining == nil || *sum.MinutesRemaining != 17 ||
|
||||
sum.AdBonusMinutes != 10 || !sum.AdUnlocked {
|
||||
t.Errorf("free summary wrong: %+v (cap=%v remaining=%v)", sum, sum.MinutesCap, sum.MinutesRemaining)
|
||||
}
|
||||
|
||||
// Pro user: unlimited (nil remaining).
|
||||
|
||||
Reference in New Issue
Block a user