Files
wangjia e023fb6579 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>
2026-07-01 23:44:10 +08:00

70 lines
2.0 KiB
Go
Raw Permalink 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.
package usage
import (
"context"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// defaultFreeDailyMinutes is the口径 fallback when the free plan row has a NULL
// daily_minutes (should not happen given the seed, but we stay safe).
const defaultFreeDailyMinutes = 10
// CheckFreeConnect enforces the free-plan connect gate and returns the user's
// remaining minutes for today (UTC). The quota is account-wide (shared across
// all devices).
//
// 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): 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 {
return 0, apierr.ErrInternal
}
limit := defaultFreeDailyMinutes
if plan.DailyMinutes.Valid {
limit = int(plan.DailyMinutes.Int64)
}
// Paid plans (no ad gate).
if !plan.AdGate {
if !plan.DailyMinutes.Valid {
return Unlimited, nil
}
day, err := svc.store.GetDay(ctx, userID, utcToday())
if err != nil {
return 0, apierr.ErrInternal
}
remaining := limit
if day != nil {
remaining = limit - day.MinutesUsed
}
if remaining < 0 {
remaining = 0
}
return remaining, nil
}
// 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
}
allowance := limit
used := 0
if day != nil {
allowance = limit + day.AdBonusMinutes
used = day.MinutesUsed
}
if used >= allowance {
return 0, apierr.ErrQuotaExhausted
}
return allowance - used, nil
}