9200600b1c
- Added a new API endpoint `GET /api/v1/smoke/home` to consolidate core modules for the home dashboard, reducing the need for multiple requests. - Updated the `smoke` routes to include the new home endpoint and improved user profile management with the addition of a `quit_date` field. - Enhanced the algorithm for calculating daily targets and next smoke suggestions, ensuring accurate future time handling and user-specific recommendations. - Improved API documentation to reflect new endpoints, response formats, and detailed field descriptions for better clarity and usability. - Refactored user authentication handling in various handlers to streamline the process and ensure consistent error responses.
292 lines
8.5 KiB
Go
292 lines
8.5 KiB
Go
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"`
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
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,
|
||
},
|
||
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)
|
||
}
|