Enhance smoking tracking API with new features and improvements

- 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.
This commit is contained in:
nepiedg
2026-01-29 17:16:35 +00:00
parent 3154365ab2
commit 9200600b1c
21 changed files with 703 additions and 178 deletions
@@ -0,0 +1,291 @@
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)
}