package service import ( "context" "errors" "fmt" "time" "gorm.io/gorm" smokemodel "wx_service/internal/smoke/model" ) type SmokeNextService struct { db *gorm.DB } func NewSmokeNextService(db *gorm.DB) *SmokeNextService { return &SmokeNextService{db: db} } type NextSmokeSuggestion struct { LastSmokeAt *time.Time `json:"last_smoke_at,omitempty"` NextSmokeAt *time.Time `json:"next_smoke_at,omitempty"` BaseIntervalMinutes int `json:"base_interval_minutes"` IntervalMinutes int `json:"interval_minutes"` Stage int `json:"stage"` Resisted7d int `json:"resisted_7d"` SleepAdjusted bool `json:"sleep_adjusted"` Algorithm string `json:"algorithm"` AsOf string `json:"as_of"` } // GetDefaultSuggestion 返回“未使用 AI 时”的默认下次抽烟时间建议(阶梯式延时算法)。 func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, asOf time.Time, planDate time.Time, profileView SmokeProfileView) (NextSmokeSuggestion, error) { now := asOf.In(time.Local) planDay := dateOnly(planDate) today := dateOnly(now) base := profileView.BaselineIntervalMinute if base <= 0 { base = 60 } lastSmokeAt, ok, err := s.loadLastActualSmokeAt(ctx, uid) if err != nil { return NextSmokeSuggestion{}, err } if !ok { lastCopy := now lastSmokeAt = &lastCopy } else if lastSmokeAt.After(now) { clamped := now lastSmokeAt = &clamped } resisted, err := s.countResistedLastDays(ctx, uid, 7) if err != nil { return NextSmokeSuggestion{}, err } stage := resisted / 5 if stage > 12 { stage = 12 } interval := base + stage*5 if interval < 5 { interval = 5 } if interval > 240 { interval = 240 } intervalDuration := time.Duration(interval) * time.Minute next := lastSmokeAt.Add(intervalDuration) if !planDay.After(today) && next.Before(now) { if intervalDuration <= 0 { next = now } else { elapsed := now.Sub(*lastSmokeAt) missed := int(elapsed / intervalDuration) if missed < 0 { missed = 0 } next = lastSmokeAt.Add(time.Duration(missed+1) * intervalDuration) } } sleepAdjusted := false var wakeUp, sleep string if profileView.Profile != nil { wakeUp = profileView.Profile.WakeUpTime sleep = profileView.Profile.SleepTime } // 如果是“生成某一天的计划”(例如明天),默认不早于该日的起床时间(若未配置则使用 07:00)。 if planDay.After(today) { minNotBefore := time.Date(planDay.Year(), planDay.Month(), planDay.Day(), 7, 0, 0, 0, time.Local) if wakeUp != "" { if m, err := parseHHMMToMinutes(wakeUp); err == nil { minNotBefore = time.Date(planDay.Year(), planDay.Month(), planDay.Day(), m/60, m%60, 0, 0, time.Local) } } if next.Before(minNotBefore) { next = minNotBefore } } if wakeUp != "" && sleep != "" { adjusted, ok, err := adjustToWakeIfInSleep(next, wakeUp, sleep) if err != nil && !errors.Is(err, ErrSmokeProfileInvalidTime) { return NextSmokeSuggestion{}, err } if ok { next = adjusted sleepAdjusted = true } } out := NextSmokeSuggestion{ BaseIntervalMinutes: base, IntervalMinutes: interval, Stage: stage, Resisted7d: resisted, SleepAdjusted: sleepAdjusted, Algorithm: "staircase_delay_v1", AsOf: now.Format(time.RFC3339), } out.LastSmokeAt = lastSmokeAt if !next.IsZero() { t := next out.NextSmokeAt = &t } return out, nil } func (s *SmokeNextService) loadLastActualSmokeAt(ctx context.Context, uid int) (*time.Time, bool, error) { var last smokemodel.SmokeLog 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 if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, false, nil } return nil, false, fmt.Errorf("load last smoke log: %w", err) } t, ok := lastEventTime(last) if !ok { return nil, false, nil } return &t, true, nil } func (s *SmokeNextService) countResistedLastDays(ctx context.Context, uid int, days int) (int, error) { if days <= 0 { days = 7 } end := dateOnly(time.Now().In(time.Local)) start := end.AddDate(0, 0, -(days - 1)) var count int64 if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Where("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 logs: %w", err) } return int(count), nil } func adjustToWakeIfInSleep(t time.Time, wakeUpHHMM, sleepHHMM string) (time.Time, bool, error) { wakeMin, err := parseHHMMToMinutes(wakeUpHHMM) if err != nil { return time.Time{}, false, ErrSmokeProfileInvalidTime } sleepMin, err := parseHHMMToMinutes(sleepHHMM) if err != nil { return time.Time{}, false, ErrSmokeProfileInvalidTime } if wakeMin == sleepMin { return t, false, nil } minutes := t.Hour()*60 + t.Minute() inSleep := isInSleepWindow(minutes, wakeMin, sleepMin) if !inSleep { return t, false, nil } wakeToday := time.Date(t.Year(), t.Month(), t.Day(), wakeMin/60, wakeMin%60, 0, 0, t.Location()) if !wakeToday.After(t) { wakeToday = wakeToday.Add(24 * time.Hour) } return wakeToday, true, nil } func isInSleepWindow(minuteOfDay int, wakeMin int, sleepMin int) bool { if minuteOfDay < 0 { minuteOfDay = 0 } if minuteOfDay >= 24*60 { minuteOfDay = 24*60 - 1 } // wake < sleep: awake=[wake,sleep), sleep=else if wakeMin < sleepMin { return minuteOfDay < wakeMin || minuteOfDay >= sleepMin } // wake > sleep: awake 跨午夜,sleep=[sleep,wake) return minuteOfDay >= sleepMin && minuteOfDay < wakeMin }