修复AI时间节点缓存过期与刷新策略
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -95,19 +96,41 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AINextSmokeSuggestion{}, err
|
return AINextSmokeSuggestion{}, err
|
||||||
}
|
}
|
||||||
|
var cachedSuggestion *AINextSmokeSuggestion
|
||||||
if cachedAdvice != nil {
|
if cachedAdvice != nil {
|
||||||
return s.buildFromCache(ctx, cachedAdvice)
|
v, buildErr := s.buildFromCache(ctx, cachedAdvice)
|
||||||
|
if buildErr != nil {
|
||||||
|
log.Printf("[smoke_ai_next] cache_build_failed uid=%d plan_date=%s advice_id=%d err=%v", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID, buildErr)
|
||||||
|
} else if !s.shouldRefreshCache(asOf, planDate, v) {
|
||||||
|
log.Printf("[smoke_ai_next] cache_hit uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
|
||||||
|
return v, nil
|
||||||
|
} else {
|
||||||
|
log.Printf("[smoke_ai_next] cache_stale uid=%d plan_date=%s advice_id=%d suggested_at=%s nodes=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID, v.SuggestedAt, len(v.TimeNodes))
|
||||||
|
cachedSuggestion = &v
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
||||||
|
if cachedSuggestion != nil {
|
||||||
|
log.Printf("[smoke_ai_next] ai_disabled_reuse_cache uid=%d plan_date=%s", user.ID, planDate.Format("2006-01-02"))
|
||||||
|
return *cachedSuggestion, nil
|
||||||
|
}
|
||||||
return AINextSmokeSuggestion{}, ErrAINextServiceDisabled
|
return AINextSmokeSuggestion{}, ErrAINextServiceDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
allowed, err := s.isAllowed(ctx, user, planDate)
|
allowed, err := s.isAllowed(ctx, user, planDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if cachedSuggestion != nil {
|
||||||
|
log.Printf("[smoke_ai_next] allow_check_failed_reuse_cache uid=%d plan_date=%s err=%v", user.ID, planDate.Format("2006-01-02"), err)
|
||||||
|
return *cachedSuggestion, nil
|
||||||
|
}
|
||||||
return AINextSmokeSuggestion{}, err
|
return AINextSmokeSuggestion{}, err
|
||||||
}
|
}
|
||||||
if !allowed {
|
if !allowed {
|
||||||
|
if cachedSuggestion != nil {
|
||||||
|
log.Printf("[smoke_ai_next] no_refresh_permission_reuse_cache uid=%d plan_date=%s", user.ID, planDate.Format("2006-01-02"))
|
||||||
|
return *cachedSuggestion, nil
|
||||||
|
}
|
||||||
return AINextSmokeSuggestion{}, ErrAINextLocked
|
return AINextSmokeSuggestion{}, ErrAINextLocked
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,13 +205,44 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
|||||||
Advice: strings.TrimSpace(output.Advice),
|
Advice: strings.TrimSpace(output.Advice),
|
||||||
TokensIn: tokensIn,
|
TokensIn: tokensIn,
|
||||||
TokensOut: tokensOut,
|
TokensOut: tokensOut,
|
||||||
CreateTime: &createTime,
|
|
||||||
UpdateTime: &updateTime,
|
UpdateTime: &updateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cachedAdvice == nil {
|
||||||
|
adviceRecord.CreateTime = &createTime
|
||||||
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
|
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
|
||||||
return AINextSmokeSuggestion{}, fmt.Errorf("save ai next smoke advice: %w", err)
|
return AINextSmokeSuggestion{}, fmt.Errorf("save ai next smoke advice: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("[smoke_ai_next] cache_write_create uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), adviceRecord.ID)
|
||||||
|
} else {
|
||||||
|
adviceRecord.ID = cachedAdvice.ID
|
||||||
|
adviceRecord.CreateTime = cachedAdvice.CreateTime
|
||||||
|
|
||||||
|
var tokensInValue interface{}
|
||||||
|
if tokensIn != nil {
|
||||||
|
tokensInValue = *tokensIn
|
||||||
|
}
|
||||||
|
var tokensOutValue interface{}
|
||||||
|
if tokensOut != nil {
|
||||||
|
tokensOutValue = *tokensOut
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&smokemodel.SmokeAIAdvice{}).
|
||||||
|
Where("id = ?", cachedAdvice.ID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"provider": adviceRecord.Provider,
|
||||||
|
"model": adviceRecord.Model,
|
||||||
|
"input_snapshot": adviceRecord.InputSnapshot,
|
||||||
|
"advice": adviceRecord.Advice,
|
||||||
|
"tokens_in": tokensInValue,
|
||||||
|
"tokens_out": tokensOutValue,
|
||||||
|
"updatetime": updateTime,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return AINextSmokeSuggestion{}, fmt.Errorf("refresh ai next smoke advice: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("[smoke_ai_next] cache_write_refresh uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), adviceRecord.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// 2) 写入时间节点(每个时间点一条)
|
// 2) 写入时间节点(每个时间点一条)
|
||||||
nodes, err := s.normalizeNodes(output.TimeNodes, asOf, planDate, notBeforeAt, profile)
|
nodes, err := s.normalizeNodes(output.TimeNodes, asOf, planDate, notBeforeAt, profile)
|
||||||
@@ -196,6 +250,14 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
|||||||
return AINextSmokeSuggestion{}, err
|
return AINextSmokeSuggestion{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cachedAdvice != nil {
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("ai_advice_id = ?", adviceRecord.ID).
|
||||||
|
Delete(&smokemodel.SmokeAINextSmoke{}).Error; err != nil {
|
||||||
|
return AINextSmokeSuggestion{}, fmt.Errorf("clear old ai next smoke nodes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.saveNodes(ctx, int(user.ID), planDate, adviceRecord.ID, notBeforeAt, suggestedAt, nodes); err != nil {
|
if err := s.saveNodes(ctx, int(user.ID), planDate, adviceRecord.ID, notBeforeAt, suggestedAt, nodes); err != nil {
|
||||||
return AINextSmokeSuggestion{}, err
|
return AINextSmokeSuggestion{}, err
|
||||||
}
|
}
|
||||||
@@ -231,6 +293,11 @@ func (s *SmokeAINextSmokeService) GetCached(ctx context.Context, user *usermodel
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AINextSmokeSuggestion{}, false, err
|
return AINextSmokeSuggestion{}, false, err
|
||||||
}
|
}
|
||||||
|
if s.shouldRefreshCache(time.Now().In(time.Local), planDate, suggestion) {
|
||||||
|
log.Printf("[smoke_ai_next] cache_auto_expired uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
|
||||||
|
return AINextSmokeSuggestion{}, false, nil
|
||||||
|
}
|
||||||
|
log.Printf("[smoke_ai_next] cache_auto_hit uid=%d plan_date=%s advice_id=%d", user.ID, planDate.Format("2006-01-02"), cachedAdvice.ID)
|
||||||
return suggestion, true, nil
|
return suggestion, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +306,7 @@ func (s *SmokeAINextSmokeService) getCachedAdvice(ctx context.Context, uid int,
|
|||||||
err := s.db.WithContext(ctx).
|
err := s.db.WithContext(ctx).
|
||||||
Where("uid = ? AND type = ? AND advice_date = ? AND prompt_version = ? AND (deletetime IS NULL OR deletetime = 0)",
|
Where("uid = ? AND type = ? AND advice_date = ? AND prompt_version = ? AND (deletetime IS NULL OR deletetime = 0)",
|
||||||
uid, SmokeAIAdviceTypeNextSmoke, dateOnly(planDate).Format("2006-01-02"), promptVersion).
|
uid, SmokeAIAdviceTypeNextSmoke, dateOnly(planDate).Format("2006-01-02"), promptVersion).
|
||||||
|
Order("id DESC").
|
||||||
First(&record).Error
|
First(&record).Error
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &record, nil
|
return &record, nil
|
||||||
@@ -342,6 +410,30 @@ func (s *SmokeAINextSmokeService) normalizeNodes(raw []string, asOf time.Time, p
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SmokeAINextSmokeService) shouldRefreshCache(asOf time.Time, planDate time.Time, suggestion AINextSmokeSuggestion) bool {
|
||||||
|
if len(suggestion.TimeNodes) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestedAt, err := parseFlexibleTime(suggestion.SuggestedAt, planDate)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
asOf = asOf.In(time.Local)
|
||||||
|
planDate = dateOnly(planDate)
|
||||||
|
if dateOnly(suggestedAt) != planDate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当天建议如果已经到点(或过期),视为缓存失效,触发刷新。
|
||||||
|
if planDate.Equal(dateOnly(asOf)) && !suggestedAt.After(asOf.Add(2*time.Minute)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SmokeAINextSmokeService) computeMinNotBefore(asOf time.Time, planDate time.Time, defaultSuggestion NextSmokeSuggestion, profile *adviceUserProfile) time.Time {
|
func (s *SmokeAINextSmokeService) computeMinNotBefore(asOf time.Time, planDate time.Time, defaultSuggestion NextSmokeSuggestion, profile *adviceUserProfile) time.Time {
|
||||||
asOf = asOf.In(time.Local)
|
asOf = asOf.In(time.Local)
|
||||||
planDate = dateOnly(planDate)
|
planDate = dateOnly(planDate)
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldRefreshCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := &SmokeAINextSmokeService{}
|
||||||
|
now := time.Date(2026, 2, 28, 10, 0, 0, 0, time.Local)
|
||||||
|
today := dateOnly(now)
|
||||||
|
tomorrow := today.AddDate(0, 0, 1)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
planDate time.Time
|
||||||
|
in AINextSmokeSuggestion
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "空节点视为失效",
|
||||||
|
planDate: today,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: time.Date(2026, 2, 28, 10, 30, 0, 0, time.Local).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "非法时间视为失效",
|
||||||
|
planDate: today,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: "bad-time",
|
||||||
|
TimeNodes: []string{"10:30"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "建议日期不匹配视为失效",
|
||||||
|
planDate: today,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: time.Date(2026, 2, 27, 10, 30, 0, 0, time.Local).Format(time.RFC3339),
|
||||||
|
TimeNodes: []string{"10:30"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "当天过期建议需要刷新",
|
||||||
|
planDate: today,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: time.Date(2026, 2, 28, 10, 1, 0, 0, time.Local).Format(time.RFC3339),
|
||||||
|
TimeNodes: []string{"10:01", "10:20"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "当天未来建议保持有效",
|
||||||
|
planDate: today,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: time.Date(2026, 2, 28, 10, 5, 0, 0, time.Local).Format(time.RFC3339),
|
||||||
|
TimeNodes: []string{"10:05", "10:30"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "明日计划不过期",
|
||||||
|
planDate: tomorrow,
|
||||||
|
in: AINextSmokeSuggestion{
|
||||||
|
SuggestedAt: time.Date(2026, 3, 1, 8, 30, 0, 0, time.Local).Format(time.RFC3339),
|
||||||
|
TimeNodes: []string{"08:30", "10:00"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := svc.shouldRefreshCache(now, tc.planDate, tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("shouldRefreshCache()=%v, want=%v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeNodesFiltersInvalidAndDuplicate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := &SmokeAINextSmokeService{}
|
||||||
|
asOf := time.Date(2026, 2, 28, 10, 0, 0, 0, time.Local)
|
||||||
|
planDate := dateOnly(asOf)
|
||||||
|
notBefore := time.Date(2026, 2, 28, 10, 10, 0, 0, time.Local)
|
||||||
|
profile := &adviceUserProfile{
|
||||||
|
WakeUpTime: "07:00",
|
||||||
|
SleepTime: "23:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := []string{"09:00", "10:05", "10:20", "10:20", "bad", "23:30", "10:40"}
|
||||||
|
got, err := svc.normalizeNodes(raw, asOf, planDate, notBefore, profile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeNodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"10:20", "10:40"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("normalizeNodes len=%d, want=%d, got=%v", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("normalizeNodes[%d]=%s, want=%s", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeMinNotBeforeForFuturePlanUsesWakeUp(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
svc := &SmokeAINextSmokeService{}
|
||||||
|
asOf := time.Date(2026, 2, 28, 23, 0, 0, 0, time.Local)
|
||||||
|
planDate := time.Date(2026, 3, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
|
||||||
|
gotWithProfile := svc.computeMinNotBefore(asOf, planDate, NextSmokeSuggestion{}, &adviceUserProfile{WakeUpTime: "08:15"})
|
||||||
|
if gotWithProfile.Hour() != 8 || gotWithProfile.Minute() != 15 {
|
||||||
|
t.Fatalf("computeMinNotBefore with wake_up_time got=%s, want=08:15", gotWithProfile.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
gotWithoutProfile := svc.computeMinNotBefore(asOf, planDate, NextSmokeSuggestion{}, nil)
|
||||||
|
if gotWithoutProfile.Hour() != 7 || gotWithoutProfile.Minute() != 0 {
|
||||||
|
t.Fatalf("computeMinNotBefore default got=%s, want=07:00", gotWithoutProfile.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user