package handler import ( "errors" "fmt" "math" "net/http" "strings" "time" "github.com/gin-gonic/gin" "wx_service/internal/middleware" "wx_service/internal/model" smokeservice "wx_service/internal/smoke/service" ) type homeDashboardResponse struct { Greeting homeGreeting `json:"greeting"` Profile smokeservice.SmokeProfileView `json:"profile"` AdviceCard homeAdviceCard `json:"advice_card"` CampaignCard homeCampaignCard `json:"campaign_card"` Timer homeTimerBlock `json:"timer"` Summary homeSummaryBlock `json:"summary"` DailySummary *homeDailySummaryBlock `json:"daily_summary,omitempty"` Motivation smokeservice.SmokeMotivation `json:"motivation"` QuickActions []homeQuickAction `json:"quick_actions"` DataSources homeDataSources `json:"data_sources"` } type homeGreeting struct { Title string `json:"title"` Subtitle string `json:"subtitle"` Nickname string `json:"nickname"` TimeOfDay string `json:"time_of_day"` AvatarURL string `json:"avatar_url"` } type homeAdviceCard struct { Title string `json:"title"` Date string `json:"date"` Message string `json:"message"` Model string `json:"model,omitempty"` Status string `json:"status"` // available | locked | unavailable | no_data | empty } type homeCampaignCard struct { Title string `json:"title"` Subtitle string `json:"subtitle"` Badge string `json:"badge"` } type homeTimerBlock struct { Label string `json:"label"` LastSmokeAt string `json:"last_smoke_at"` SecondsSinceLast int `json:"seconds_since_last"` NextSuggestedAt string `json:"next_suggested_at"` NextSuggestedClock string `json:"next_suggested_clock"` NotBeforeAt string `json:"not_before_at"` SuggestionSource string `json:"suggestion_source"` SuggestionAlgorithm string `json:"suggestion_algorithm"` AITimeNodes []string `json:"ai_time_nodes,omitempty"` AIAdvice string `json:"ai_advice,omitempty"` AIModel string `json:"ai_model,omitempty"` } type homeSummaryBlock struct { TodayCount int `json:"today_count"` DailyTarget int `json:"daily_target"` ResistedCount int `json:"resisted_count"` ReducedFromYesterday int `json:"reduced_from_yesterday"` ExceededYesterday bool `json:"exceeded_yesterday"` ProfileCompleted bool `json:"profile_completed"` } type homeQuickAction struct { Type string `json:"type"` Title string `json:"title"` Primary bool `json:"primary"` } type homeDataSources struct { AdviceDate string `json:"ai_advice_date"` PlanDate string `json:"plan_date"` } type homeDailySummaryBlock struct { Date string `json:"date"` Content string `json:"content"` Model string `json:"model,omitempty"` Status string `json:"status"` // available | locked | no_data | empty } func (h *SmokeHandler) Home(c *gin.Context) { user := middleware.MustCurrentUser(c) ctx := c.Request.Context() now := time.Now().In(time.Local) planDate := dateOnlyLocal(now) profileView, err := h.smokeProfileService.GetView(ctx, int(user.ID)) if err != nil { if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "作息时间格式错误,应为 HH:MM")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试")) return } defaultSuggestion, err := h.smokeNextService.GetDefaultSuggestion(ctx, int(user.ID), now, planDate, profileView) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "计算失败,请稍后重试")) return } homeSummary, err := h.smokeLogService.HomeSummary(ctx, int(user.ID), now) // HomeSummary 已经包含 last smoke、今日数据等,直接复用 if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试")) return } motivation, err := h.smokeLogService.Motivation(ctx, int(user.ID), now, profileView.Profile) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成激励语失败,请稍后重试")) return } adviceCard := buildAdviceCard() adviceDate := yesterdayDate() adviceCard.Date = adviceDate.Format(dateLayout) if record, err := h.smokeAIAdviceService.GetOrGenerate(ctx, user, adviceDate, smokeservice.DefaultAdvicePromptVersion); err != nil { switch { case errors.Is(err, smokeservice.ErrAIAdviceLocked): adviceCard.Status = "locked" case errors.Is(err, smokeservice.ErrAIServiceDisabled): adviceCard.Status = "unavailable" case errors.Is(err, smokeservice.ErrNoSmokeLogs): adviceCard.Status = "no_data" default: c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 AI 建议失败,请稍后重试")) return } } else if record != nil { adviceCard.Message = record.Advice adviceCard.Model = record.Model adviceCard.Status = "available" } timerBlock := homeTimerBlock{ Label: "距上次抽烟", LastSmokeAt: formatRFC3339(homeSummary.LastSmokeAt), SecondsSinceLast: homeSummary.SecondsSinceLast, NextSuggestedAt: formatRFC3339(defaultSuggestion.NextSmokeAt), NextSuggestedClock: formatClock(defaultSuggestion.NextSmokeAt), NotBeforeAt: formatRFC3339(defaultSuggestion.NextSmokeAt), SuggestionSource: "default", SuggestionAlgorithm: defaultSuggestion.Algorithm, } // 尝试读取 AI 缓存,有则覆盖 timer 的建议时间(默认算法作为保底) if aiSuggestion, ok, _ := h.smokeAINextService.GetCached(ctx, user, planDate, "v1"); ok { timerBlock.SuggestionSource = "ai" timerBlock.SuggestionAlgorithm = "ai_next_smoke_v1" timerBlock.AITimeNodes = aiSuggestion.TimeNodes timerBlock.AIAdvice = aiSuggestion.Advice timerBlock.AIModel = aiSuggestion.Model if aiSuggestion.SuggestedAt != "" { timerBlock.NextSuggestedAt = aiSuggestion.SuggestedAt if t, err := time.Parse(time.RFC3339, aiSuggestion.SuggestedAt); err == nil { timerBlock.NextSuggestedClock = t.In(time.Local).Format("15:04") } } if aiSuggestion.NotBeforeAt != "" { timerBlock.NotBeforeAt = aiSuggestion.NotBeforeAt } } // 尝试读取今日 AI 总结缓存 var dailySummaryBlock *homeDailySummaryBlock if summaryRecord, err := h.smokeAIAdviceService.GetCachedByType(ctx, int(user.ID), smokeservice.SmokeAIAdviceTypeDailySummary, planDate, smokeservice.DefaultSummaryPromptVersion); err == nil && summaryRecord != nil { dailySummaryBlock = &homeDailySummaryBlock{ Date: summaryRecord.AdviceDate.Format(dateLayout), Content: summaryRecord.Advice, Model: summaryRecord.Model, Status: "available", } } response := homeDashboardResponse{ Greeting: greetingBlock(user.NickName, user.AvatarURL, now), Profile: profileView, AdviceCard: homeAdviceCard{ Title: adviceCard.Title, Date: adviceCard.Date, Message: adviceCard.Message, Model: adviceCard.Model, Status: adviceCard.Status, }, CampaignCard: homeCampaignCard{ Title: "绿色生活,从戒烟开始", Subtitle: "BRAND CAMPAIGN", Badge: "广告", }, Timer: timerBlock, Summary: homeSummaryBlock{ TodayCount: homeSummary.TodayCount, DailyTarget: profileDailyTarget(profileView, now), ResistedCount: homeSummary.ResistedCount, ReducedFromYesterday: homeSummary.ReducedFromYesterday, ExceededYesterday: homeSummary.ExceededYesterday, ProfileCompleted: profileView.IsCompleted, }, DailySummary: dailySummaryBlock, Motivation: motivation, QuickActions: []homeQuickAction{ {Type: "log_smoke", Title: "记录抽烟", Primary: false}, {Type: "resist", Title: "想抽忍住了", Primary: true}, }, DataSources: homeDataSources{ AdviceDate: adviceCard.Date, PlanDate: planDate.Format(dateLayout), }, } c.JSON(http.StatusOK, model.Success(response)) } func buildAdviceCard() homeAdviceCard { return homeAdviceCard{ Title: "智能控烟建议", Status: "empty", } } func greetingBlock(nickname string, avatar string, now time.Time) homeGreeting { greetKey, greetText, sub := greetingText(now) name := strings.TrimSpace(nickname) if name == "" { name = "朋友" } return homeGreeting{ Title: fmt.Sprintf("%s,%s", greetText, name), Subtitle: sub, Nickname: name, TimeOfDay: greetKey, AvatarURL: avatar, } } func greetingText(now time.Time) (string, string, string) { hour := now.Hour() switch { case hour >= 5 && hour < 11: return "morning", "早安", "今天也是清爽的一天" case hour >= 11 && hour < 14: return "noon", "午安", "补充水分和能量" case hour >= 14 && hour < 19: return "afternoon", "下午好", "保持呼吸节奏" case hour >= 19 || hour < 5: return "evening", "晚上好", "放松心情早点休息" default: return "hello", "你好", "和自己好好相处" } } func formatRFC3339(t *time.Time) string { if t == nil { return "" } return t.In(time.Local).Format(time.RFC3339) } func formatClock(t *time.Time) string { if t == nil { return "" } return t.In(time.Local).Format("15:04") } func profileDailyTarget(view smokeservice.SmokeProfileView, now time.Time) int { if view.Profile == nil || view.Profile.BaselineCigsPerDay <= 0 { return 0 } base := view.Profile.BaselineCigsPerDay if view.Profile.QuitDate == nil || view.Profile.OnboardingCompletedAt == nil { return base } start := dateOnlyLocal(*view.Profile.OnboardingCompletedAt) quit := dateOnlyLocal(*view.Profile.QuitDate) today := dateOnlyLocal(now) if !today.Before(quit) { return 0 } if !quit.After(start) { return base } totalDays := daysBetweenDate(start, quit) if totalDays <= 0 { return base } remainingDays := daysBetweenDate(today, quit) if remainingDays < 0 { return 0 } if remainingDays > totalDays { remainingDays = totalDays } target := int(math.Round(float64(base) * float64(remainingDays) / float64(totalDays))) if remainingDays > 0 && target < 1 { target = 1 } if target > base { target = base } return target } func daysBetweenDate(start, end time.Time) int { start = dateOnlyLocal(start) end = dateOnlyLocal(end) return int(end.Sub(start).Hours() / 24) }