修复AI时间节点缓存过期与刷新策略

This commit is contained in:
hello-dd-code
2026-02-28 16:24:00 +08:00
parent 1b8ff310eb
commit ec9517e248
2 changed files with 230 additions and 4 deletions
@@ -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,13 +205,44 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
Advice: strings.TrimSpace(output.Advice),
TokensIn: tokensIn,
TokensOut: tokensOut,
CreateTime: &createTime,
UpdateTime: &updateTime,
}
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) 写入时间节点(每个时间点一条)
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
}
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))
}
}