6cf7eb2294
- Added new API endpoint `GET /api/v1/smoke/next_smoke_time` to provide AI-generated suggestions for the next smoking time based on user data. - Introduced a new database table `fa_smoke_ai_next_smoke` to store structured AI time node suggestions. - Updated smoke handler and service to integrate the new AI next smoke time functionality. - Enhanced documentation to reflect the new API endpoint and its usage, including details on how to generate AI time nodes.
203 lines
5.4 KiB
Go
203 lines
5.4 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)
|
|
|
|
base := profileView.BaselineIntervalMinute
|
|
if base <= 0 {
|
|
base = 60
|
|
}
|
|
|
|
lastSmokeAt, ok, err := s.loadLastActualSmokeAt(ctx, uid)
|
|
if err != nil {
|
|
return NextSmokeSuggestion{}, err
|
|
}
|
|
if !ok {
|
|
nowCopy := now
|
|
lastSmokeAt = &nowCopy
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
next := lastSmokeAt.Add(time.Duration(interval) * time.Minute)
|
|
sleepAdjusted := false
|
|
|
|
var wakeUp, sleep string
|
|
if profileView.Profile != nil {
|
|
wakeUp = profileView.Profile.WakeUpTime
|
|
sleep = profileView.Profile.SleepTime
|
|
}
|
|
|
|
// 如果是“生成某一天的计划”(例如明天),默认不早于该日的起床时间(若未配置则使用 07:00)。
|
|
today := dateOnly(now)
|
|
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("NOT (level = 0 AND 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
|
|
}
|