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
+13
View File
@@ -41,6 +41,19 @@ type AdVerifier interface {
Provider() string
}
// DevVerifier is a placeholder AdVerifier that accepts any receipt. It exists
// so the免费版看广告加时 flow can be exercised end-to-end with the client's
// placeholder ad dialog before a real ad SDK (AdMob SSV) is wired in. Nonce
// replay protection still applies at the service layer, so a token can only be
// redeemed once. MUST NOT be used in production with real ad revenue.
type DevVerifier struct{}
// Verify always succeeds.
func (DevVerifier) Verify(context.Context, AdVerifyRequest) error { return nil }
// Provider names this placeholder verifier.
func (DevVerifier) Provider() string { return "dev" }
// --------------------------------------------------------------------------
// AdMob Server-Side Verification (SSV)
// --------------------------------------------------------------------------
+14 -5
View File
@@ -144,9 +144,16 @@ type adsUnlockRequest struct {
AdToken string `json:"ad_token"`
}
// ServeHTTP implements http.Handler. On success it returns 204 No Content
// (matching the OpenAPI contract), including the idempotent already-unlocked
// case.
// adsUnlockResponse reports the minutes granted by this rewarded-ad view and
// the account's new remaining minutes for today, so the client can refresh its
// quota without a second /me round-trip.
type adsUnlockResponse struct {
GrantedMinutes int `json:"granted_minutes"`
MinutesRemaining int `json:"minutes_remaining"`
}
// ServeHTTP implements http.Handler. On success it returns 200 with the granted
// minutes and new remaining quota (免费版累加式看广告加时).
func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -168,13 +175,15 @@ func (h *AdsUnlockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
_, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken)
granted, remaining, apiErr := h.svc.UnlockAd(r.Context(), userID, req.DeviceID, req.AdToken)
if apiErr != nil {
apierr.WriteJSON(w, adsErrorStatus(apiErr.Code), apiErr)
return
}
w.WriteHeader(http.StatusNoContent)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(adsUnlockResponse{GrantedMinutes: granted, MinutesRemaining: remaining})
}
// adsErrorStatus maps an ads-unlock error code to an HTTP status.
+15 -12
View File
@@ -11,16 +11,16 @@ import (
const defaultFreeDailyMinutes = 10
// CheckFreeConnect enforces the free-plan connect gate and returns the user's
// remaining minutes for today (UTC). It is called by #5's connect endpoint to
// derive the free credential's TTL.
// remaining minutes for today (UTC). The quota is account-wide (shared across
// all devices).
//
// Rules:
// Rules (免费版累加式看广告加时):
// - plan.ad_gate == false (pro/team): not minute-gated. Returns Unlimited
// when daily_minutes is NULL, otherwise the remaining minutes for the day.
// - plan.ad_gate == true (free): today's ad_unlocked_at must be set and
// minutes_used must be below daily_minutes (free = 10). Returns the
// remaining minutes; otherwise a bilingual semantic error
// (AD_NOT_UNLOCKED / QUOTA_EXHAUSTED).
// - plan.ad_gate == true (free): the day's allowance = daily_minutes (free=10)
// + ad_bonus_minutes (accumulated by watching rewarded ads). No ad is
// required to connect at all — the base 10 minutes are always available.
// Returns allowance minutes_used; QUOTA_EXHAUSTED when that reaches 0.
func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remainingMinutes int, apiErr *apierr.Error) {
plan, err := svc.store.EffectivePlan(ctx, userID)
if err != nil {
@@ -51,16 +51,19 @@ func (svc *Service) CheckFreeConnect(ctx context.Context, userID int64) (remaini
return remaining, nil
}
// Free plan: require ad unlock + remaining minutes.
// Free plan: allowance = base daily + ad bonus; remaining = allowance used.
day, err := svc.store.GetDay(ctx, userID, utcToday())
if err != nil {
return 0, apierr.ErrInternal
}
if day == nil || !day.AdUnlockedAt.Valid {
return 0, apierr.ErrAdNotUnlocked
allowance := limit
used := 0
if day != nil {
allowance = limit + day.AdBonusMinutes
used = day.MinutesUsed
}
if day.MinutesUsed >= limit {
if used >= allowance {
return 0, apierr.ErrQuotaExhausted
}
return limit - day.MinutesUsed, nil
return allowance - used, nil
}
+43 -19
View File
@@ -13,6 +13,14 @@ import (
// nowFunc is overridable in tests to pin "today".
var nowFunc = time.Now
const (
// adBonusPerAd is how many minutes one rewarded-ad view grants (免费版加时).
adBonusPerAd = 10
// adDailyBonusCeiling caps the total ad-granted minutes per UTC day, so the
// free tier can't be extended indefinitely by ad-watching.
adDailyBonusCeiling = 120
)
// utcToday returns the current UTC calendar date (time truncated).
func utcToday() time.Time {
n := nowFunc().UTC()
@@ -159,8 +167,10 @@ func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([]
// TodaySummary is the /v1/me today_usage block.
type TodaySummary struct {
MinutesUsed int `json:"minutes_used"`
MinutesCap *int `json:"minutes_cap"` // 当日额度 = daily + ad_bonus; nil = unlimited
MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team)
AdUnlocked bool `json:"ad_unlocked"`
AdBonusMinutes int `json:"ad_bonus_minutes"` // 当日已通过看广告累加的分钟
AdUnlocked bool `json:"ad_unlocked"` // 历史字段:当日曾解锁过(bonus>0)
}
// TodaySummary returns the current user's usage summary for today (UTC),
@@ -176,34 +186,38 @@ func (svc *Service) TodaySummary(ctx context.Context, userID int64) (*TodaySumma
}
used := 0
adUnlocked := false
bonus := 0
if day != nil {
used = day.MinutesUsed
adUnlocked = day.AdUnlockedAt.Valid
bonus = day.AdBonusMinutes
}
out := &TodaySummary{MinutesUsed: used, AdUnlocked: adUnlocked}
out := &TodaySummary{MinutesUsed: used, AdBonusMinutes: bonus, AdUnlocked: bonus > 0}
if plan.DailyMinutes.Valid {
remaining := int(plan.DailyMinutes.Int64) - used
cap := int(plan.DailyMinutes.Int64) + bonus
remaining := cap - used
if remaining < 0 {
remaining = 0
}
out.MinutesCap = &cap
out.MinutesRemaining = &remaining
}
return out, nil
}
// UnlockAd verifies a rewarded-ad receipt and records the day's ad-unlock.
// UnlockAd verifies a rewarded-ad receipt and grants additional free minutes.
//
// Flow: validate inputs → verify the receipt with the ad network → consume the
// ad_token as a one-time nonce (replay-protected) → set ad_unlocked_at. A
// second unlock on a day already unlocked is idempotent (alreadyUnlocked=true).
func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (alreadyUnlocked bool, apiErr *apierr.Error) {
// ad_token as a one-time nonce (replay-protected) → add adBonusPerAd minutes to
// the day's ad bonus (capped at adDailyBonusCeiling). It returns the granted
// minutes (0 when the daily ceiling was already reached) and the new remaining
// minutes for today so the client can refresh its quota immediately.
func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (granted, remaining int, apiErr *apierr.Error) {
if deviceID == "" || adToken == "" {
return false, apierr.ErrBadRequest
return 0, 0, apierr.ErrBadRequest
}
if svc.verifier == nil {
return false, apierr.ErrInternal
return 0, 0, apierr.ErrInternal
}
// 1. Authenticate the receipt with the ad network.
@@ -212,22 +226,32 @@ func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToke
DeviceID: deviceID,
AdToken: adToken,
}); err != nil {
return false, apierr.ErrAdVerifyFailed
return 0, 0, apierr.ErrAdVerifyFailed
}
// 2. One-time nonce: a genuine receipt may only be redeemed once.
if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil {
return false, apierr.ErrInternal
return 0, 0, apierr.ErrInternal
} else if dup {
return false, apierr.ErrAdReplay
return 0, 0, apierr.ErrAdReplay
}
// 3. Record the unlock (idempotent per UTC day).
already, err := svc.store.MarkAdUnlocked(ctx, userID, utcToday())
if err != nil {
return false, apierr.ErrInternal
// 3. Grant the reward: add adBonusPerAd minutes, capped at the daily ceiling.
_, g, addErr := svc.store.AddAdBonusMinutes(ctx, userID, utcToday(), adBonusPerAd, adDailyBonusCeiling)
if addErr != nil {
return 0, 0, apierr.ErrInternal
}
return already, nil
granted = g
// 4. Recompute remaining for the client to refresh its quota immediately.
summary, sErr := svc.TodaySummary(ctx, userID)
if sErr != nil {
return 0, 0, sErr
}
if summary.MinutesRemaining != nil {
remaining = *summary.MinutesRemaining
}
return granted, remaining, nil
}
// consumeAdNonce atomically records the ad_token so it cannot be reused.
+75 -9
View File
@@ -29,11 +29,12 @@ type Plan struct {
// minute counts plus the ad-unlock timestamp — never destinations or DNS, per
// the no-log policy.
type DailyUsage struct {
Date time.Time
BytesUp uint64
BytesDown uint64
MinutesUsed int
AdUnlockedAt sql.NullTime
Date time.Time
BytesUp uint64
BytesDown uint64
MinutesUsed int
AdBonusMinutes int // 当日看广告累加解锁的额外分钟(免费版加时)
AdUnlockedAt sql.NullTime
}
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
@@ -86,7 +87,7 @@ func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64,
// filled here; zero-filling is the handler's responsibility.
func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DailyUsage, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
`SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date BETWEEN ? AND ?
ORDER BY date ASC`,
@@ -99,7 +100,7 @@ func (s *Store) GetUsageRange(ctx context.Context, userID int64, from, to time.T
var out []DailyUsage
for rows.Next() {
var u DailyUsage
if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt); err != nil {
if err := rows.Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt); err != nil {
return nil, fmt.Errorf("store.GetUsageRange scan: %w", err)
}
out = append(out, u)
@@ -212,11 +213,11 @@ func (s *Store) DeviceUsageRange(ctx context.Context, userID int64, from, to tim
func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*DailyUsage, error) {
var u DailyUsage
err := s.db.QueryRowContext(ctx,
`SELECT date, bytes_up, bytes_down, minutes_used, ad_unlocked_at
`SELECT date, bytes_up, bytes_down, minutes_used, ad_bonus_minutes, ad_unlocked_at
FROM usage_daily
WHERE user_id = ? AND date = ?`,
userID, day.UTC().Format(dateLayout)).
Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdUnlockedAt)
Scan(&u.Date, &u.BytesUp, &u.BytesDown, &u.MinutesUsed, &u.AdBonusMinutes, &u.AdUnlockedAt)
if err == sql.ErrNoRows {
return nil, nil
}
@@ -226,10 +227,75 @@ func (s *Store) GetDay(ctx context.Context, userID int64, day time.Time) (*Daily
return &u, nil
}
// AddAdBonusMinutes adds `add` minutes to usage_daily.ad_bonus_minutes for
// (userID, day), capped so the day's total bonus never exceeds `ceiling`. It
// returns the new bonus total and the minutes actually granted this call
// (granted = newBonus previous, i.e. 0 when the ceiling was already reached).
// Runs inside a transaction with SELECT … FOR UPDATE so concurrent ad-unlocks
// accumulate correctly (免费版累加式看广告加时).
//
// This is the additive successor to MarkAdUnlocked's per-day boolean unlock:
// each rewarded ad grants +add minutes (repeatable) up to the daily ceiling.
func (s *Store) AddAdBonusMinutes(ctx context.Context, userID int64, day time.Time, add, ceiling int) (newBonus, granted int, err error) {
d := day.UTC().Format(dateLayout)
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return 0, 0, fmt.Errorf("store.AddAdBonusMinutes begin: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
var cur int
row := tx.QueryRowContext(ctx,
`SELECT ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(),
userID, d)
switch scanErr := row.Scan(&cur); scanErr {
case nil:
newBonus = cur + add
if ceiling > 0 && newBonus > ceiling {
newBonus = ceiling
}
if newBonus != cur {
if _, uErr := tx.ExecContext(ctx,
`UPDATE usage_daily SET ad_bonus_minutes = ? WHERE user_id = ? AND date = ?`,
newBonus, userID, d); uErr != nil {
return 0, 0, fmt.Errorf("store.AddAdBonusMinutes update: %w", uErr)
}
}
case sql.ErrNoRows:
cur = 0
newBonus = add
if ceiling > 0 && newBonus > ceiling {
newBonus = ceiling
}
if _, iErr := tx.ExecContext(ctx,
`INSERT INTO usage_daily (user_id, date, ad_bonus_minutes) VALUES (?, ?, ?)`,
userID, d, newBonus); iErr != nil {
return 0, 0, fmt.Errorf("store.AddAdBonusMinutes insert: %w", iErr)
}
default:
return 0, 0, fmt.Errorf("store.AddAdBonusMinutes select: %w", scanErr)
}
if cErr := tx.Commit(); cErr != nil {
return 0, 0, fmt.Errorf("store.AddAdBonusMinutes commit: %w", cErr)
}
committed = true
return newBonus, newBonus - cur, nil
}
// MarkAdUnlocked sets usage_daily.ad_unlocked_at for (userID, day) to now if it
// is not already set. It returns alreadyUnlocked=true when the day was already
// unlocked (making a second call idempotent). Runs inside a transaction with
// SELECT … FOR UPDATE to be safe under concurrent unlock attempts.
//
// Deprecated: the免费版 ad model moved from a per-day boolean unlock to additive
// minutes (see AddAdBonusMinutes). Retained only for backward compatibility.
func (s *Store) MarkAdUnlocked(ctx context.Context, userID int64, day time.Time) (alreadyUnlocked bool, err error) {
d := day.UTC().Format(dateLayout)
now := time.Now().UTC()
+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).