221 lines
5.8 KiB
Go
221 lines
5.8 KiB
Go
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("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 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
|
|
}
|