package service import ( "context" "fmt" "math" "time" "gorm.io/gorm" smokemodel "wx_service/internal/smoke/model" ) const defaultCigsPerPack = 20 type SmokeStatsRequest struct { Range string Start time.Time End time.Time PrevStart time.Time PrevEnd time.Time TrendUnit string AsOf time.Time } type SmokeStatsResult struct { Range string `json:"range"` Start string `json:"start"` End string `json:"end"` TrendUnit string `json:"trend_unit"` Trend []SmokeStatsTrend `json:"trend"` DailyAverage int `json:"daily_average"` ChangePercent int `json:"change_percent"` Money SmokeStatsMoney `json:"money"` Health SmokeStatsHealth `json:"health"` StreakDays int `json:"streak_days"` ResistedTotal int `json:"resisted_total"` } type SmokeStatsTrend struct { Label string `json:"label"` Count int `json:"count"` } type SmokeStatsMoney struct { Available bool `json:"available"` PackPriceCent int `json:"pack_price_cent,omitempty"` CigsPerPack int `json:"cigs_per_pack,omitempty"` ExpectedTotal int `json:"expected_total,omitempty"` ActualTotal int `json:"actual_total,omitempty"` SavedCent int `json:"saved_cent,omitempty"` } type SmokeStatsHealth struct { Available bool `json:"available"` SmokeFreeMinutes int `json:"smoke_free_minutes,omitempty"` LungRecoveryPercent int `json:"lung_recovery_percent,omitempty"` Milestones []HealthMilestone `json:"milestones,omitempty"` } type HealthMilestone struct { Name string `json:"name"` Minutes int `json:"minutes"` Reached bool `json:"reached"` } func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequest, profile *smokemodel.SmokeUserProfile) (SmokeStatsResult, error) { start := dateOnly(req.Start) end := dateOnly(req.End) trendUnit := req.TrendUnit var ( trend []SmokeStatsTrend total int64 err error ) if trendUnit == "month" { trend, total, err = s.loadMonthlyTrend(ctx, uid, start, end) } else { trend, total, err = s.loadDailyTrend(ctx, uid, start, end) } if err != nil { return SmokeStatsResult{}, err } trend = limitTrend(trend, 7) dayCount := daysBetweenInclusive(start, end) dailyAvg := 0 if dayCount > 0 { dailyAvg = int(math.Round(float64(total) / float64(dayCount))) } prevTotal, err := s.sumCigs(ctx, uid, req.PrevStart, req.PrevEnd) if err != nil { return SmokeStatsResult{}, err } changePercent := 0 if prevTotal > 0 { changePercent = int(math.Round((float64(total) - float64(prevTotal)) / float64(prevTotal) * 100)) } resistedTotal, err := s.countResisted(ctx, uid, start, end) if err != nil { return SmokeStatsResult{}, err } streakDays, err := s.computeStreakDays(ctx, uid, req.AsOf) if err != nil { return SmokeStatsResult{}, err } money := s.computeMoney(ctx, uid, profile, int(total), start, end) health, err := s.computeHealth(ctx, uid, req.AsOf) if err != nil { return SmokeStatsResult{}, err } return SmokeStatsResult{ Range: req.Range, Start: start.Format("2006-01-02"), End: end.Format("2006-01-02"), TrendUnit: trendUnit, Trend: trend, DailyAverage: dailyAvg, ChangePercent: changePercent, Money: money, Health: health, StreakDays: streakDays, ResistedTotal: resistedTotal, }, nil } func limitTrend(items []SmokeStatsTrend, max int) []SmokeStatsTrend { if max <= 0 || len(items) <= max { return items } lastIndex := len(items) - 1 out := make([]SmokeStatsTrend, 0, max) seen := make(map[int]struct{}, max) for i := 0; i < max; i++ { pos := int(math.Round(float64(i) * float64(lastIndex) / float64(max-1))) if _, ok := seen[pos]; ok { continue } seen[pos] = struct{}{} out = append(out, items[pos]) } return out } func (s *SmokeLogService) loadDailyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) { type dailyCount struct { SmokeTime time.Time `gorm:"column:smoke_time"` Total int64 `gorm:"column:total"` } var rows []dailyCount if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Select("smoke_time, SUM(num) AS total"). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). Group("smoke_time"). Order("smoke_time ASC"). Find(&rows).Error; err != nil { return nil, 0, fmt.Errorf("load daily trend: %w", err) } counts := make(map[string]int64, len(rows)) var total int64 for _, row := range rows { key := dateOnly(row.SmokeTime).Format("2006-01-02") counts[key] = row.Total total += row.Total } out := make([]SmokeStatsTrend, 0, daysBetweenInclusive(start, end)) for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { label := day.Format("2006-01-02") out = append(out, SmokeStatsTrend{ Label: label, Count: int(counts[label]), }) } return out, total, nil } func (s *SmokeLogService) loadMonthlyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) { type monthlyCount struct { Month string `gorm:"column:month"` Total int64 `gorm:"column:total"` } var rows []monthlyCount if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Select("DATE_FORMAT(smoke_time, '%Y-%m') AS month, SUM(num) AS total"). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). Group("month"). Order("month ASC"). Find(&rows).Error; err != nil { return nil, 0, fmt.Errorf("load monthly trend: %w", err) } counts := make(map[string]int64, len(rows)) var total int64 for _, row := range rows { counts[row.Month] = row.Total total += row.Total } out := make([]SmokeStatsTrend, 0, 12) for cursor := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.Local); !cursor.After(end); cursor = cursor.AddDate(0, 1, 0) { label := cursor.Format("2006-01") out = append(out, SmokeStatsTrend{ Label: label, Count: int(counts[label]), }) } return out, total, nil } func (s *SmokeLogService) sumCigs(ctx context.Context, uid int, start, end time.Time) (int64, error) { start = dateOnly(start) end = dateOnly(end) var total int64 if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). Select("COALESCE(SUM(num), 0)"). Scan(&total).Error; err != nil { return 0, fmt.Errorf("sum cigs: %w", err) } return total, nil } func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end time.Time) (int, error) { start = dateOnly(start) end = dateOnly(end) var count int64 if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("level = 0 AND num = 0"). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). Count(&count).Error; err != nil { return 0, fmt.Errorf("count resisted: %w", err) } return int(count), nil } func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) { asOf = dateOnly(asOf) start := asOf.AddDate(0, 0, -400) type row struct { SmokeTime time.Time `gorm:"column:smoke_time"` } var rows []row if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Distinct("smoke_time"). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), asOf.Format("2006-01-02")). Order("smoke_time DESC"). Find(&rows).Error; err != nil { return 0, fmt.Errorf("load streak days: %w", err) } daySet := make(map[string]bool, len(rows)) for _, r := range rows { daySet[dateOnly(r.SmokeTime).Format("2006-01-02")] = true } streak := 0 for day := asOf; ; day = day.AddDate(0, 0, -1) { key := day.Format("2006-01-02") if !daySet[key] { break } streak++ } return streak, nil } func (s *SmokeLogService) computeMoney(ctx context.Context, uid int, profile *smokemodel.SmokeUserProfile, actualTotal int, start, end time.Time) SmokeStatsMoney { if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 { return SmokeStatsMoney{Available: false} } activeDays, err := s.countLogDays(ctx, uid, start, end) if err != nil { return SmokeStatsMoney{Available: false} } if activeDays <= 0 { return SmokeStatsMoney{ Available: true, PackPriceCent: profile.PackPriceCent, CigsPerPack: defaultCigsPerPack, ExpectedTotal: 0, ActualTotal: actualTotal, SavedCent: 0, } } expectedTotal := profile.BaselineCigsPerDay * activeDays savedCigs := expectedTotal - actualTotal if savedCigs < 0 { savedCigs = 0 } savedPacks := float64(savedCigs) / float64(defaultCigsPerPack) savedCent := int(math.Round(savedPacks * float64(profile.PackPriceCent))) return SmokeStatsMoney{ Available: true, PackPriceCent: profile.PackPriceCent, CigsPerPack: defaultCigsPerPack, ExpectedTotal: expectedTotal, ActualTotal: actualTotal, SavedCent: savedCent, } } func (s *SmokeLogService) countLogDays(ctx context.Context, uid int, start, end time.Time) (int, error) { start = dateOnly(start) end = dateOnly(end) type row struct { SmokeTime time.Time `gorm:"column:smoke_time"` } var rows []row if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Distinct("smoke_time"). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). Find(&rows).Error; err != nil { return 0, fmt.Errorf("count log days: %w", err) } return len(rows), nil } func (s *SmokeLogService) computeHealth(ctx context.Context, uid int, asOf time.Time) (SmokeStatsHealth, error) { lastSmokeAt, err := s.loadLastActualSmokeAt(ctx, uid) if err != nil { return SmokeStatsHealth{}, err } if lastSmokeAt == nil { return SmokeStatsHealth{Available: false}, nil } minutes := int(asOf.Sub(*lastSmokeAt).Minutes()) if minutes < 0 { minutes = 0 } return SmokeStatsHealth{ Available: true, SmokeFreeMinutes: minutes, LungRecoveryPercent: computeLungRecoveryPercent(minutes), Milestones: buildHealthMilestones(minutes), }, nil } func (s *SmokeLogService) loadLastActualSmokeAt(ctx context.Context, uid int) (*time.Time, error) { var last smokemodel.SmokeLog if err := s.db.WithContext(ctx). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("num > 0"). Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC"). Order("id DESC"). Limit(1). Take(&last).Error; err != nil { if err == gorm.ErrRecordNotFound { return nil, nil } return nil, fmt.Errorf("load last smoke log: %w", err) } if t, ok := lastEventTime(last); ok { return &t, nil } return nil, nil } func computeLungRecoveryPercent(smokeFreeMinutes int) int { days := float64(smokeFreeMinutes) / (24 * 60) switch { case days < 14: return int(math.Round((days / 14) * 15)) case days < 30: return int(math.Round(15 + ((days-14)/16)*15)) case days < 90: return int(math.Round(30 + ((days-30)/60)*20)) default: val := 50 + ((days-90)/275)*50 if val > 100 { val = 100 } return int(math.Round(val)) } } func buildHealthMilestones(smokeFreeMinutes int) []HealthMilestone { steps := []struct { name string minutes int }{ {"心率血压恢复正常", 20}, {"血氧水平恢复", 8 * 60}, {"心脏病风险开始下降", 24 * 60}, {"嗅觉味觉开始恢复", 48 * 60}, {"肺功能提升 15%", 14 * 24 * 60}, {"肺功能提升 30%", 30 * 24 * 60}, {"肺功能提升 50%", 90 * 24 * 60}, {"心脏病风险降低 50%", 365 * 24 * 60}, } out := make([]HealthMilestone, 0, len(steps)) for _, step := range steps { out = append(out, HealthMilestone{ Name: step.name, Minutes: step.minutes, Reached: smokeFreeMinutes >= step.minutes, }) } return out } func daysBetweenInclusive(start, end time.Time) int { start = dateOnly(start) end = dateOnly(end) if end.Before(start) { return 0 } return int(end.Sub(start).Hours()/24) + 1 }