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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user