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` usage points for userID bucketed in the // CLIENT's timezone (offsetMin = client UTC offset in minutes, e.g. 480 for // UTC+8), missing days zero-filled, oldest first. days clamped to [1,90]. // // It reads UTC hourly buckets (usage_hourly) and re-aggregates by local calendar // day, so "today" matches the user's local date regardless of server timezone // (fixes "29号却显示28号数据"). offsetMin out of ±14h falls back to 0 (UTC). // deviceUUID == "" → account-level curve; non-empty → that device's curve only. func (svc *Service) UsageCurve(ctx context.Context, userID int64, days, offsetMin int, deviceUUID string) ([]UsagePoint, *apierr.Error) { if days < 1 { days = 7 } if days > 90 { days = 90 } if offsetMin < -14*60 || offsetMin > 14*60 { offsetMin = 0 } loc := time.FixedZone("client", offsetMin*60) now := nowFunc().UTC() ln := now.In(loc) localToday := time.Date(ln.Year(), ln.Month(), ln.Day(), 0, 0, 0, 0, loc) fromLocal := localToday.AddDate(0, 0, -(days - 1)) fromHour := fromLocal.UTC().Unix() / 3600 toHour := now.Unix() / 3600 var rows []HourBucket var err error if deviceUUID == "" { rows, err = svc.store.HourlyRange(ctx, userID, fromHour, toHour) } else { rows, err = svc.store.DeviceHourlyRange(ctx, userID, deviceUUID, fromHour, toHour) } if err != nil { return nil, apierr.ErrInternal } type agg struct { up, down uint64 min int } byDay := make(map[string]*agg) for _, b := range rows { key := time.Unix(b.Hour*3600, 0).In(loc).Format(dateLayout) a := byDay[key] if a == nil { a = &agg{} byDay[key] = a } a.up += b.BytesUp a.down += b.BytesDown a.min += b.MinutesUsed } points := make([]UsagePoint, 0, days) for i := 0; i < days; i++ { d := fromLocal.AddDate(0, 0, i).Format(dateLayout) if a, ok := byDay[d]; ok { points = append(points, UsagePoint{Date: d, BytesUp: a.up, BytesDown: a.down, MinutesUsed: a.min}) } else { points = append(points, UsagePoint{Date: d}) } } return points, nil } // DeviceUsagePoint is one device's aggregated usage over the requested window, // used by the stats page's per-device ("下分设备") breakdown. type DeviceUsagePoint struct { UUID string `json:"uuid"` Name string `json:"name"` Platform string `json:"platform"` // ios | android | windows | macos BytesUp uint64 `json:"bytes_up"` BytesDown uint64 `json:"bytes_down"` MinutesUsed int `json:"minutes_used"` } // DeviceUsage returns per-device usage totals for userID over the last `days` // (UTC), busiest device first. days is clamped to [1, 90]. Devices with no // usage in the window are omitted (the response is never nil — empty slice). func (svc *Service) DeviceUsage(ctx context.Context, userID int64, days int) ([]DeviceUsagePoint, *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.DeviceUsageRange(ctx, userID, from, today) if err != nil { return nil, apierr.ErrInternal } points := make([]DeviceUsagePoint, 0, len(rows)) for _, r := range rows { points = append(points, DeviceUsagePoint{ UUID: r.UUID, Name: r.Name, Platform: r.Platform, BytesUp: r.BytesUp, BytesDown: r.BytesDown, MinutesUsed: r.MinutesUsed, }) } 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 }