修复AI时间节点缓存过期与刷新策略
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -95,19 +96,41 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
||||
if err != nil {
|
||||
return AINextSmokeSuggestion{}, err
|
||||
}
|
||||
var cachedSuggestion *AINextSmokeSuggestion
|
||||
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 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
|
||||
}
|
||||
|
||||
allowed, err := s.isAllowed(ctx, user, planDate)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -182,12 +205,43 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
||||
Advice: strings.TrimSpace(output.Advice),
|
||||
TokensIn: tokensIn,
|
||||
TokensOut: tokensOut,
|
||||
CreateTime: &createTime,
|
||||
UpdateTime: &updateTime,
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
|
||||
return AINextSmokeSuggestion{}, fmt.Errorf("save ai next smoke advice: %w", err)
|
||||
if cachedAdvice == nil {
|
||||
adviceRecord.CreateTime = &createTime
|
||||
if err := s.db.WithContext(ctx).Create(&adviceRecord).Error; err != nil {
|
||||
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) 写入时间节点(每个时间点一条)
|
||||
@@ -196,6 +250,14 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
|
||||
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 {
|
||||
return AINextSmokeSuggestion{}, err
|
||||
}
|
||||
@@ -231,6 +293,11 @@ func (s *SmokeAINextSmokeService) GetCached(ctx context.Context, user *usermodel
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -239,6 +306,7 @@ func (s *SmokeAINextSmokeService) getCachedAdvice(ctx context.Context, uid int,
|
||||
err := s.db.WithContext(ctx).
|
||||
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).
|
||||
Order("id DESC").
|
||||
First(&record).Error
|
||||
if err == nil {
|
||||
return &record, nil
|
||||
@@ -342,6 +410,30 @@ func (s *SmokeAINextSmokeService) normalizeNodes(raw []string, asOf time.Time, p
|
||||
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 {
|
||||
asOf = asOf.In(time.Local)
|
||||
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