package handler import ( "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "wx_service/internal/middleware" "wx_service/internal/model" smokeservice "wx_service/internal/smoke/service" ) type nextSmokeTimeUnifiedResponse struct { Source string `json:"source"` NotBeforeAt string `json:"not_before_at"` SuggestedAt string `json:"suggested_at"` LastSmokeAt string `json:"last_smoke_at,omitempty"` TodayCount int `json:"today_count"` Resisted int `json:"resisted_count"` Reduced int `json:"reduced_from_yesterday"` Exceeded bool `json:"exceeded_yesterday"` TimeNodes []string `json:"time_nodes,omitempty"` Advice string `json:"advice,omitempty"` Default nextSmokeDefaultResponse `json:"default"` AI *nextSmokeAIResponse `json:"ai,omitempty"` } func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) { user, ok := middleware.CurrentUser(c) if !ok { c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) return } asOf := time.Now().In(time.Local) planDate := dateOnlyLocal(asOf) if v := strings.TrimSpace(c.Query("date")); v != "" { switch strings.ToLower(v) { case "today": planDate = dateOnlyLocal(asOf) case "tomorrow": planDate = dateOnlyLocal(asOf).AddDate(0, 0, 1) default: parsed, err := time.ParseInLocation(dateLayout, v, time.Local) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD 或 today/tomorrow")) return } planDate = parsed } } if planDate.Before(dateOnlyLocal(asOf)) { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 不能早于今天")) return } view, err := h.smokeProfileService.GetView(c.Request.Context(), 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(c.Request.Context(), int(user.ID), asOf, planDate, view) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "计算失败,请稍后重试")) return } homeSummary, err := h.smokeLogService.HomeSummary(c.Request.Context(), int(user.ID), asOf) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试")) return } mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "auto"))) formatPtr := func(t *time.Time) string { if t == nil { return "" } return t.In(time.Local).Format(time.RFC3339) } formatTimeString := func(value string) string { value = strings.TrimSpace(value) if value == "" { return "" } if t, err := time.Parse(time.RFC3339, value); err == nil { return t.In(time.Local).Format(time.RFC3339) } if t, err := time.ParseInLocation(dateTimeLayout, value, time.Local); err == nil { return t.In(time.Local).Format(time.RFC3339) } return value } formatDefault := func(s smokeservice.NextSmokeSuggestion) nextSmokeDefaultResponse { out := nextSmokeDefaultResponse{ LastSmokeAt: formatPtr(s.LastSmokeAt), NextSmokeAt: formatPtr(s.NextSmokeAt), BaseIntervalMinutes: s.BaseIntervalMinutes, IntervalMinutes: s.IntervalMinutes, Stage: s.Stage, Resisted7d: s.Resisted7d, SleepAdjusted: s.SleepAdjusted, Algorithm: s.Algorithm, } out.AsOf = formatTimeString(s.AsOf) return out } formatAI := func(s smokeservice.AINextSmokeSuggestion) nextSmokeAIResponse { return nextSmokeAIResponse{ PlanDate: s.PlanDate, NotBeforeAt: formatTimeString(s.NotBeforeAt), SuggestedAt: formatTimeString(s.SuggestedAt), TimeNodes: s.TimeNodes, Advice: s.Advice, PromptVersion: s.PromptVersion, Model: s.Model, Provider: s.Provider, } } resp := nextSmokeTimeUnifiedResponse{ Source: "default", NotBeforeAt: formatPtr(defaultSuggestion.NextSmokeAt), SuggestedAt: formatPtr(defaultSuggestion.NextSmokeAt), LastSmokeAt: formatPtr(homeSummary.LastSmokeAt), TodayCount: homeSummary.TodayCount, Resisted: homeSummary.ResistedCount, Reduced: homeSummary.ReducedFromYesterday, Exceeded: homeSummary.ExceededYesterday, Default: formatDefault(defaultSuggestion), } // mode=default: 永远返回默认建议 if mode == "default" { c.JSON(http.StatusOK, model.Success(resp)) return } // mode=auto: 仅在“已存在 AI 时间节点”时使用 AI(不主动生成) // mode=ai: 尝试生成/刷新 AI,再优先使用 AI;失败则回落到默认 var ai smokeservice.AINextSmokeSuggestion var hasAI bool if mode == "ai" { v, err := h.smokeAINextService.GetOrGenerate(c.Request.Context(), user, asOf, planDate, "v1", defaultSuggestion) if err != nil { switch { case errors.Is(err, smokeservice.ErrAINextLocked): c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "需要观看广告解锁后才可生成")) return case errors.Is(err, smokeservice.ErrAINextServiceDisabled): c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "AI 服务暂不可用,请联系管理员")) return default: c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成 AI 建议失败,请稍后重试")) return } } ai = v hasAI = len(ai.TimeNodes) > 0 } else { v, ok, err := h.smokeAINextService.GetCached(c.Request.Context(), user, planDate, "v1") if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 AI 建议失败,请稍后重试")) return } ai = v hasAI = ok && len(ai.TimeNodes) > 0 } if hasAI { resp.Source = "ai" resp.NotBeforeAt = formatTimeString(ai.NotBeforeAt) resp.SuggestedAt = formatTimeString(ai.SuggestedAt) resp.TimeNodes = ai.TimeNodes resp.Advice = ai.Advice formattedAI := formatAI(ai) resp.AI = &formattedAI } c.JSON(http.StatusOK, model.Success(resp)) } func dateOnlyLocal(t time.Time) time.Time { local := t.In(time.Local) return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local) } type nextSmokeDefaultResponse struct { LastSmokeAt string `json:"last_smoke_at,omitempty"` NextSmokeAt string `json:"next_smoke_at,omitempty"` BaseIntervalMinutes int `json:"base_interval_minutes"` IntervalMinutes int `json:"interval_minutes"` Stage int `json:"stage"` Resisted7d int `json:"resisted_7d"` SleepAdjusted bool `json:"sleep_adjusted"` Algorithm string `json:"algorithm"` AsOf string `json:"as_of"` } type nextSmokeAIResponse struct { PlanDate string `json:"plan_date"` NotBeforeAt string `json:"not_before_at"` SuggestedAt string `json:"suggested_at"` TimeNodes []string `json:"time_nodes"` Advice string `json:"advice"` PromptVersion string `json:"prompt_version"` Model string `json:"model,omitempty"` Provider string `json:"provider,omitempty"` }