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 const ( // adBonusPerAd is how many minutes one rewarded-ad view grants (免费版加时). adBonusPerAd = 10 // adDailyBonusCeiling caps the total ad-granted minutes per UTC day, so the // free tier can't be extended indefinitely by ad-watching. adDailyBonusCeiling = 120 ) // 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"` MinutesCap *int `json:"minutes_cap"` // 当日额度 = daily + ad_bonus; nil = unlimited MinutesRemaining *int `json:"minutes_remaining"` // nil = unlimited (pro/team) AdBonusMinutes int `json:"ad_bonus_minutes"` // 当日已通过看广告累加的分钟 AdUnlocked bool `json:"ad_unlocked"` // 历史字段:当日曾解锁过(bonus>0) } // 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 bonus := 0 if day != nil { used = day.MinutesUsed bonus = day.AdBonusMinutes } out := &TodaySummary{MinutesUsed: used, AdBonusMinutes: bonus, AdUnlocked: bonus > 0} if plan.DailyMinutes.Valid { cap := int(plan.DailyMinutes.Int64) + bonus remaining := cap - used if remaining < 0 { remaining = 0 } out.MinutesCap = &cap out.MinutesRemaining = &remaining } return out, nil } // UnlockAd verifies a rewarded-ad receipt and grants additional free minutes. // // Flow: validate inputs → verify the receipt with the ad network → consume the // ad_token as a one-time nonce (replay-protected) → add adBonusPerAd minutes to // the day's ad bonus (capped at adDailyBonusCeiling). It returns the granted // minutes (0 when the daily ceiling was already reached) and the new remaining // minutes for today so the client can refresh its quota immediately. func (svc *Service) UnlockAd(ctx context.Context, userID int64, deviceID, adToken string) (granted, remaining int, apiErr *apierr.Error) { if deviceID == "" || adToken == "" { return 0, 0, apierr.ErrBadRequest } if svc.verifier == nil { return 0, 0, 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 0, 0, apierr.ErrAdVerifyFailed } // 2. One-time nonce: a genuine receipt may only be redeemed once. if dup, err := svc.consumeAdNonce(ctx, adToken); err != nil { return 0, 0, apierr.ErrInternal } else if dup { return 0, 0, apierr.ErrAdReplay } // 3. Grant the reward: add adBonusPerAd minutes, capped at the daily ceiling. _, g, addErr := svc.store.AddAdBonusMinutes(ctx, userID, utcToday(), adBonusPerAd, adDailyBonusCeiling) if addErr != nil { return 0, 0, apierr.ErrInternal } granted = g // 4. Recompute remaining for the client to refresh its quota immediately. summary, sErr := svc.TodaySummary(ctx, userID) if sErr != nil { return 0, 0, sErr } if summary.MinutesRemaining != nil { remaining = *summary.MinutesRemaining } return granted, remaining, 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 }