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:
wangjia
2026-07-01 23:44:10 +08:00
parent 0bedd1c4d1
commit e023fb6579
19 changed files with 367 additions and 101 deletions
+49 -38
View File
@@ -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).