From ec9517e248c97257c31b327267cc35248fa6de9d Mon Sep 17 00:00:00 2001 From: hello-dd-code Date: Sat, 28 Feb 2026 16:24:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DAI=E6=97=B6=E9=97=B4=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E7=BC=93=E5=AD=98=E8=BF=87=E6=9C=9F=E4=B8=8E=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/smoke_ai_next_smoke_service.go | 100 ++++++++++++- .../smoke_ai_next_smoke_service_test.go | 134 ++++++++++++++++++ 2 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 internal/smoke/service/smoke_ai_next_smoke_service_test.go diff --git a/internal/smoke/service/smoke_ai_next_smoke_service.go b/internal/smoke/service/smoke_ai_next_smoke_service.go index 903e560..a3d626b 100644 --- a/internal/smoke/service/smoke_ai_next_smoke_service.go +++ b/internal/smoke/service/smoke_ai_next_smoke_service.go @@ -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) diff --git a/internal/smoke/service/smoke_ai_next_smoke_service_test.go b/internal/smoke/service/smoke_ai_next_smoke_service_test.go new file mode 100644 index 0000000..6d67dea --- /dev/null +++ b/internal/smoke/service/smoke_ai_next_smoke_service_test.go @@ -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)) + } +}