package usage import ( "context" "crypto/sha256" "encoding/hex" "time" "github.com/redis/go-redis/v9" "github.com/wangjia/pangolin/server/internal/apierr" ) // nowFunc is overridable in tests to pin "today". var nowFunc = time.Now // utcToday returns the current UTC calendar date (time truncated). func utcToday() time.Time { n := nowFunc().UTC() return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, time.UTC) } // Service serves usage queries, the /v1/me usage summary, the ads-unlock flow, // and the free-plan connect quota check. type Service struct { store *Store rdb *redis.Client verifier AdVerifier adNonceTTL time.Duration } // NewService builds a Service. rdb may be nil (ad-token replay protection is // then disabled — not recommended in production). verifier may be nil if the // ads-unlock endpoint is not mounted. adNonceTTL <= 0 defaults to 1h. func NewService(store *Store, rdb *redis.Client, verifier AdVerifier, adNonceTTL time.Duration) *Service { if adNonceTTL <= 0 { adNonceTTL = time.Hour } return &Service{store: store, rdb: rdb, verifier: verifier, adNonceTTL: adNonceTTL} } // UsagePoint is one day of the usage curve. type UsagePoint struct { Date string `json:"date"` // YYYY-MM-DD (UTC) BytesUp uint64 `json:"bytes_up"` BytesDown uint64 `json:"bytes_down"` MinutesUsed int `json:"minutes_used"` } // UsageCurve returns the last `days` daily points for userID (UTC), with // missing days zero-filled, oldest first. days is clamped to [1, 90]. func (svc *Service) UsageCurve(ctx context.Context, userID int64, days int) ([]UsagePoint, *apierr.Error) { if days < 1 { days = 7 } if days > 90 { days = 90 } today := utcToday() from := today.AddDate(0, 0, -(days - 1)) rows, err := svc.store.GetUsageRange(ctx, userID, from, today) if err != nil { return nil, apierr.ErrInternal } byDate := make(map[string]DailyUsage, len(rows)) for _, r := range rows { byDate[r.Date.UTC().Format(dateLayout)] = r } points := make([]UsagePoint, 0, days) for i := 0; i < days; i++ { d := from.AddDate(0, 0, i).Format(dateLayout) if u, ok := byDate[d]; ok { points = append(points, UsagePoint{ Date: d, BytesUp: u.BytesUp, BytesDown: u.BytesDown, MinutesUsed: u.MinutesUsed, }) } else { points = append(points, UsagePoint{Date: d}) } } return points, nil } // TodaySummary is the /v1/me today_usage block. type TodaySummary struct { MinutesUsed int `json:"minutes_used"` MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team) AdUnlocked bool `json:"ad_unlocked"` } // TodaySummary returns the current user's usage summary for today (UTC), // reflecting plan limits and ad-unlock state. func (svc *Service) TodaySummary(ctx context.Context, userID int64) (*TodaySummary, *apierr.Error) { plan, err := svc.store.EffectivePlan(ctx, userID) if err != nil { return nil, apierr.ErrInternal } day, err := svc.store.GetDay(ctx, userID, utcToday()) if err != nil { return nil, apierr.ErrInternal } used := 0 adUnlocked := false if day != nil { used = day.MinutesUsed adUnlocked = day.AdUnlockedAt.Valid } out := &TodaySummary{MinutesUsed: used, AdUnlocked: adUnlocked} if plan.DailyMinutes.Valid { remaining := int(plan.DailyMinutes.Int64) - used if remaining < 0 { remaining = 0 } out.MinutesRemaining = &remaining } return out, nil } // UnlockAd verifies a rewarded-ad receipt and records the day's ad-unlock. // // 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) { if deviceID == "" || adToken == "" { return false, apierr.ErrBadRequest } if svc.verifier == nil { return false, apierr.ErrInternal } // 1. Authenticate the receipt with the ad network. if err := svc.verifier.Verify(ctx, AdVerifyRequest{ UserID: userID, DeviceID: deviceID, AdToken: adToken, }); err != nil { return false, 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 } else if dup { return false, 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 } return already, nil } // consumeAdNonce atomically records the ad_token so it cannot be reused. // Returns dup=true if the token was already consumed. No-ops (dup=false) when // Redis is not configured. func (svc *Service) consumeAdNonce(ctx context.Context, adToken string) (bool, error) { if svc.rdb == nil { return false, nil } sum := sha256.Sum256([]byte(adToken)) key := "ads:nonce:" + hex.EncodeToString(sum[:]) set, err := svc.rdb.SetNX(ctx, key, "1", svc.adNonceTTL).Result() if err != nil { return false, err } return !set, nil }