Files
wx_service/internal/smoke/handler/smoke_home_handler.go
T

334 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"errors"
"fmt"
"log"
"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:
log.Printf("[smoke_home] ai advice degraded uid=%d date=%s err=%v", user.ID, adviceDate.Format(dateLayout), err)
adviceCard.Status = "unavailable"
}
} 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)
}