e023fb6579
免费版此前形同虚设: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>
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package usage
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
"time"
|
||
|
||
dbx "github.com/wangjia/pangolin/server/internal/db"
|
||
)
|
||
|
||
// Unlimited is the sentinel returned by CheckFreeConnect for plans with no
|
||
// daily-minute cap (pro / team). Callers (e.g. the connect endpoint) treat it
|
||
// as "no minute limit; use the default paid credential TTL".
|
||
const Unlimited = -1
|
||
|
||
// dateLayout is the canonical UTC day format used as the usage_daily.date key
|
||
// and in the API response.
|
||
const dateLayout = "2006-01-02"
|
||
|
||
// Plan is the minimal subset of the plans row needed for quota decisions.
|
||
type Plan struct {
|
||
Code string // free | pro | team
|
||
DailyMinutes sql.NullInt64 // free=10; NULL = unlimited
|
||
AdGate bool // free=true: daily rewarded-ad unlock required
|
||
}
|
||
|
||
// DailyUsage mirrors a usage_daily row. It carries only aggregate byte and
|
||
// 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
|
||
AdBonusMinutes int // 当日看广告累加解锁的额外分钟(免费版加时)
|
||
AdUnlockedAt sql.NullTime
|
||
}
|
||
|
||
// Store wraps a *sql.DB and exposes the usage_daily / plans / users queries the
|
||
// usage package needs.
|
||
type Store struct {
|
||
db *sql.DB
|
||
dialect dbx.Dialect
|
||
}
|
||
|
||
// NewStore creates a Store backed by the given connection pool (MySQL or SQLite).
|
||
func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} }
|
||
|
||
// AggregateUsage accumulates one usage delta into usage_daily for (userID, day)
|
||
// using INSERT … ON DUPLICATE KEY UPDATE so concurrent reports from multiple
|
||
// nodes add up correctly. day is truncated to its UTC calendar date.
|
||
func (s *Store) AggregateUsage(ctx context.Context, userID int64, day time.Time, bytesUp, bytesDown uint64, minutes int) error {
|
||
d := day.UTC().Format(dateLayout)
|
||
_, err := s.db.ExecContext(ctx,
|
||
`INSERT INTO usage_daily (user_id, date, bytes_up, bytes_down, minutes_used)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`+s.dialect.Upsert([]string{"user_id", "date"},
|
||
"bytes_up = bytes_up + EXCLUDED.bytes_up",
|
||
"bytes_down = bytes_down + EXCLUDED.bytes_down",
|
||
"minutes_used = minutes_used + EXCLUDED.minutes_used"),
|
||
userID, d, bytesUp, bytesDown, minutes)
|
||
if err != nil {
|
||
return fmt.Errorf("store.AggregateUsage: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LookupUserIDByDPUUID resolves a data-plane credential UUID to the owning
|
||
// user_id. Returns (0, nil) when no user matches (e.g. a rotated/stale dp_uuid)
|
||
// so the caller can decide to silently drop the report.
|
||
func (s *Store) LookupUserIDByDPUUID(ctx context.Context, dpUUID string) (int64, error) {
|
||
var id int64
|
||
err := s.db.QueryRowContext(ctx,
|
||
`SELECT id FROM users WHERE dp_uuid = ? LIMIT 1`, dpUUID).Scan(&id)
|
||
if err == sql.ErrNoRows {
|
||
return 0, nil
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("store.LookupUserIDByDPUUID: %w", err)
|
||
}
|
||
return id, nil
|
||
}
|
||
|
||
// GetUsageRange returns the usage_daily rows for userID with date in
|
||
// [from, to] (inclusive, UTC dates), ordered ascending. Missing days are NOT
|
||
// 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_bonus_minutes, ad_unlocked_at
|
||
FROM usage_daily
|
||
WHERE user_id = ? AND date BETWEEN ? AND ?
|
||
ORDER BY date ASC`,
|
||
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store.GetUsageRange: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []DailyUsage
|
||
for rows.Next() {
|
||
var u DailyUsage
|
||
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)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// HourBucket is one UTC epoch-hour's account usage (read from usage_hourly).
|
||
type HourBucket struct {
|
||
Hour int64 // unix epoch hour (UTC) = floor(unix_seconds / 3600)
|
||
BytesUp uint64
|
||
BytesDown uint64
|
||
MinutesUsed int
|
||
}
|
||
|
||
// HourlyRange returns usage_hourly rows for userID with hour in [fromHour, toHour]
|
||
// (inclusive, epoch hours), oldest first. Empty hours are simply absent.
|
||
func (s *Store) HourlyRange(ctx context.Context, userID, fromHour, toHour int64) ([]HourBucket, error) {
|
||
rows, err := s.db.QueryContext(ctx,
|
||
`SELECT hour, bytes_up, bytes_down, minutes_used
|
||
FROM usage_hourly
|
||
WHERE user_id = ? AND hour BETWEEN ? AND ?
|
||
ORDER BY hour ASC`,
|
||
userID, fromHour, toHour)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store.HourlyRange: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []HourBucket
|
||
for rows.Next() {
|
||
var b HourBucket
|
||
if err := rows.Scan(&b.Hour, &b.BytesUp, &b.BytesDown, &b.MinutesUsed); err != nil {
|
||
return nil, fmt.Errorf("store.HourlyRange scan: %w", err)
|
||
}
|
||
out = append(out, b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// DeviceHourlyRange returns one device's UTC hourly buckets (usage_device_hourly)
|
||
// for hour in [fromHour, toHour], oldest first. The JOIN both resolves the
|
||
// client-facing device UUID to its internal id and enforces ownership (user_id),
|
||
// so a user can never read another user's device curve.
|
||
func (s *Store) DeviceHourlyRange(ctx context.Context, userID int64, deviceUUID string, fromHour, toHour int64) ([]HourBucket, error) {
|
||
rows, err := s.db.QueryContext(ctx,
|
||
`SELECT uh.hour, uh.bytes_up, uh.bytes_down, uh.minutes_used
|
||
FROM usage_device_hourly uh
|
||
JOIN devices d ON d.id = uh.device_id
|
||
WHERE d.user_id = ? AND d.uuid = ? AND uh.hour BETWEEN ? AND ?
|
||
ORDER BY uh.hour ASC`,
|
||
userID, deviceUUID, fromHour, toHour)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store.DeviceHourlyRange: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []HourBucket
|
||
for rows.Next() {
|
||
var b HourBucket
|
||
if err := rows.Scan(&b.Hour, &b.BytesUp, &b.BytesDown, &b.MinutesUsed); err != nil {
|
||
return nil, fmt.Errorf("store.DeviceHourlyRange scan: %w", err)
|
||
}
|
||
out = append(out, b)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// DeviceUsageRow is one device's aggregated usage over a date window, joined to
|
||
// the devices table for display metadata. It is the per-device ("下分设备")
|
||
// counterpart of DailyUsage's account rollup.
|
||
type DeviceUsageRow struct {
|
||
UUID string
|
||
Name string
|
||
Platform string
|
||
BytesUp uint64
|
||
BytesDown uint64
|
||
MinutesUsed int
|
||
}
|
||
|
||
// DeviceUsageRange returns each device's summed usage over [from, to] (inclusive
|
||
// UTC dates) for userID, one row per device, ordered by total bytes descending.
|
||
// Devices with no usage in the window are omitted. All non-aggregate columns
|
||
// are in GROUP BY for MySQL ONLY_FULL_GROUP_BY portability (sqlite tolerant).
|
||
func (s *Store) DeviceUsageRange(ctx context.Context, userID int64, from, to time.Time) ([]DeviceUsageRow, error) {
|
||
rows, err := s.db.QueryContext(ctx,
|
||
`SELECT d.uuid, d.name, d.platform,
|
||
SUM(ud.bytes_up), SUM(ud.bytes_down), SUM(ud.minutes_used)
|
||
FROM usage_device_daily ud
|
||
JOIN devices d ON d.id = ud.device_id
|
||
WHERE ud.user_id = ? AND ud.date BETWEEN ? AND ?
|
||
GROUP BY ud.device_id, d.uuid, d.name, d.platform
|
||
ORDER BY (SUM(ud.bytes_up) + SUM(ud.bytes_down)) DESC`,
|
||
userID, from.UTC().Format(dateLayout), to.UTC().Format(dateLayout))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store.DeviceUsageRange: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
var out []DeviceUsageRow
|
||
for rows.Next() {
|
||
var r DeviceUsageRow
|
||
if err := rows.Scan(&r.UUID, &r.Name, &r.Platform, &r.BytesUp, &r.BytesDown, &r.MinutesUsed); err != nil {
|
||
return nil, fmt.Errorf("store.DeviceUsageRange scan: %w", err)
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// GetDay returns the usage_daily row for (userID, day), or nil if none exists.
|
||
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_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.AdBonusMinutes, &u.AdUnlockedAt)
|
||
if err == sql.ErrNoRows {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, fmt.Errorf("store.GetDay: %w", err)
|
||
}
|
||
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()
|
||
|
||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||
if err != nil {
|
||
return false, fmt.Errorf("store.MarkAdUnlocked begin: %w", err)
|
||
}
|
||
committed := false
|
||
defer func() {
|
||
if !committed {
|
||
_ = tx.Rollback()
|
||
}
|
||
}()
|
||
|
||
var unlockedAt sql.NullTime
|
||
row := tx.QueryRowContext(ctx,
|
||
`SELECT ad_unlocked_at FROM usage_daily WHERE user_id = ? AND date = ? `+s.dialect.LockForUpdate(),
|
||
userID, d)
|
||
switch err := row.Scan(&unlockedAt); err {
|
||
case nil:
|
||
if unlockedAt.Valid {
|
||
// Already unlocked today → idempotent success.
|
||
if cErr := tx.Commit(); cErr != nil {
|
||
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
|
||
}
|
||
committed = true
|
||
return true, nil
|
||
}
|
||
if _, uErr := tx.ExecContext(ctx,
|
||
`UPDATE usage_daily SET ad_unlocked_at = ?
|
||
WHERE user_id = ? AND date = ? AND ad_unlocked_at IS NULL`,
|
||
now, userID, d); uErr != nil {
|
||
return false, fmt.Errorf("store.MarkAdUnlocked update: %w", uErr)
|
||
}
|
||
case sql.ErrNoRows:
|
||
if _, iErr := tx.ExecContext(ctx,
|
||
`INSERT INTO usage_daily (user_id, date, ad_unlocked_at)
|
||
VALUES (?, ?, ?)`,
|
||
userID, d, now); iErr != nil {
|
||
return false, fmt.Errorf("store.MarkAdUnlocked insert: %w", iErr)
|
||
}
|
||
default:
|
||
return false, fmt.Errorf("store.MarkAdUnlocked select: %w", err)
|
||
}
|
||
|
||
if cErr := tx.Commit(); cErr != nil {
|
||
return false, fmt.Errorf("store.MarkAdUnlocked commit: %w", cErr)
|
||
}
|
||
committed = true
|
||
return false, nil
|
||
}
|
||
|
||
// EffectivePlan returns the plan that currently governs userID: the highest
|
||
// tier among the user's non-expired subscriptions, falling back to the free
|
||
// plan when the user has no active subscription (trial-expired free state).
|
||
func (s *Store) EffectivePlan(ctx context.Context, userID int64) (*Plan, error) {
|
||
var p Plan
|
||
err := s.db.QueryRowContext(ctx,
|
||
`SELECT p.code, p.daily_minutes, p.ad_gate
|
||
FROM subscriptions s
|
||
JOIN plans p ON p.id = s.plan_id
|
||
WHERE s.user_id = ? AND s.expires_at > ?
|
||
ORDER BY CASE p.code WHEN 'team' THEN 0 WHEN 'pro' THEN 1 WHEN 'free' THEN 2 ELSE 3 END,
|
||
s.expires_at DESC
|
||
LIMIT 1`,
|
||
userID, time.Now().UTC()).Scan(&p.Code, &p.DailyMinutes, &p.AdGate)
|
||
if err == nil {
|
||
return &p, nil
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
return nil, fmt.Errorf("store.EffectivePlan: %w", err)
|
||
}
|
||
|
||
// No active subscription → free plan.
|
||
if err := s.db.QueryRowContext(ctx,
|
||
`SELECT code, daily_minutes, ad_gate FROM plans WHERE code = 'free'`).
|
||
Scan(&p.Code, &p.DailyMinutes, &p.AdGate); err != nil {
|
||
return nil, fmt.Errorf("store.EffectivePlan free fallback: %w", err)
|
||
}
|
||
return &p, nil
|
||
}
|