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:
@@ -146,6 +146,10 @@ func (m *mockNodeStore) AccountDayBytes(_ context.Context, _ int64, _ time.Time)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) AccountDayMinutes(_ context.Context, _ int64, _ time.Time) (int, int, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
func (m *mockNodeStore) CountActiveDevices(_ context.Context, _ int64, _ time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -109,6 +109,11 @@ type NodeStore interface {
|
||||
// date — the basis for the GB 综合配额 connect gate. 0 when no usage yet.
|
||||
AccountDayBytes(ctx context.Context, userID int64, date time.Time) (int64, error)
|
||||
|
||||
// AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes
|
||||
// for userID on date — the basis for the免费版分钟配额 connect gate. Both 0
|
||||
// when no usage row exists yet. Quota is account-wide (shared across devices).
|
||||
AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error)
|
||||
|
||||
// CountActiveDevices counts the user's devices seen since cutoff (active). Used
|
||||
// by the connect device-limit backstop; stale rows are excluded.
|
||||
CountActiveDevices(ctx context.Context, userID int64, cutoff time.Time) (int, error)
|
||||
@@ -478,6 +483,22 @@ func (s *SQLNodeStore) AccountDayBytes(ctx context.Context, userID int64, date t
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
// AccountDayMinutes returns the account's minutes_used and ad_bonus_minutes for
|
||||
// the day (0,0 when no usage row yet).
|
||||
func (s *SQLNodeStore) AccountDayMinutes(ctx context.Context, userID int64, date time.Time) (used, bonus int, err error) {
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT minutes_used, ad_bonus_minutes FROM usage_daily WHERE user_id = ? AND date = ?`,
|
||||
userID, date.Format("2006-01-02"),
|
||||
).Scan(&used, &bonus)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("nodes.SQLNodeStore.AccountDayMinutes: %w", err)
|
||||
}
|
||||
return used, bonus, nil
|
||||
}
|
||||
|
||||
// CountActiveDevices counts the user's devices seen within the active window
|
||||
// (last_seen > cutoff). Mirrors devices.Store.CountActiveDevices for the connect
|
||||
// backstop; stale/never-seen rows are excluded.
|
||||
|
||||
Reference in New Issue
Block a user