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
+2 -10
View File
@@ -19,11 +19,7 @@ type unlockAIAdviceRequest struct {
}
func (h *SmokeHandler) GetAIAdvice(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
dateStr := c.Query("date")
adviceDate := yesterdayDate()
@@ -61,11 +57,7 @@ func (h *SmokeHandler) GetAIAdvice(c *gin.Context) {
}
func (h *SmokeHandler) UnlockAIAdvice(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
var req unlockAIAdviceRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
+21 -42
View File
@@ -5,6 +5,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -53,11 +54,7 @@ type createSmokeLogRequest struct {
}
func (h *SmokeHandler) Create(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
var req createSmokeLogRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -130,11 +127,7 @@ type resistedSmokeLogRequest struct {
// Resist 表示“想抽但忍住了”:在 fa_smoke_log 中写入 level=0,num=0。
func (h *SmokeHandler) Resist(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
var req resistedSmokeLogRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
@@ -178,11 +171,7 @@ func (h *SmokeHandler) Resist(c *gin.Context) {
}
func (h *SmokeHandler) Get(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
@@ -204,14 +193,15 @@ func (h *SmokeHandler) Get(c *gin.Context) {
}
func (h *SmokeHandler) List(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
listType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "all")))
if listType != "all" && listType != "smoke" && listType != "resisted" {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "type 应为 all|smoke|resisted"))
return
}
var start *time.Time
if v := c.Query("start"); v != "" {
@@ -237,6 +227,7 @@ func (h *SmokeHandler) List(c *gin.Context) {
PageSize: pageSize,
Start: start,
End: end,
Type: listType,
})
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询列表失败,请稍后重试"))
@@ -252,11 +243,7 @@ func (h *SmokeHandler) List(c *gin.Context) {
}
func (h *SmokeHandler) Dashboard(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
now := time.Now()
defaultStart, defaultEnd := defaultDashboardRange(now)
@@ -303,11 +290,7 @@ func (h *SmokeHandler) Dashboard(c *gin.Context) {
}
func (h *SmokeHandler) LatestLogs(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
limit, err := strconv.Atoi(c.DefaultQuery("limit", "20"))
if err != nil {
@@ -341,11 +324,7 @@ type updateSmokeLogRequest struct {
}
func (h *SmokeHandler) Update(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
@@ -391,8 +370,12 @@ func (h *SmokeHandler) Update(c *gin.Context) {
} else {
parsed, err := time.ParseInLocation(dateTimeLayout, *req.SmokeAt, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "smoke_at 格式错误,应为 YYYY-MM-DD HH:MM:SS"))
return
parsedRFC, errRFC := time.Parse(time.RFC3339, *req.SmokeAt)
if errRFC != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "smoke_at 格式错误,应为 YYYY-MM-DD HH:MM:SS 或 RFC3339"))
return
}
parsed = parsedRFC.In(time.Local)
}
smokeAt = &parsed
}
@@ -420,11 +403,7 @@ func (h *SmokeHandler) Update(c *gin.Context) {
}
func (h *SmokeHandler) Delete(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
@@ -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)
}
@@ -11,11 +11,7 @@ import (
)
func (h *SmokeHandler) Motivation(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
profile, err := h.smokeProfileService.Get(c.Request.Context(), int(user.ID))
if err != nil {
+1 -5
View File
@@ -29,11 +29,7 @@ type nextSmokeTimeUnifiedResponse struct {
}
func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
asOf := time.Now().In(time.Local)
planDate := dateOnlyLocal(asOf)
+23 -11
View File
@@ -4,6 +4,8 @@ import (
"errors"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -22,14 +24,12 @@ type upsertSmokeProfileRequest struct {
WakeUpTime *string `json:"wake_up_time"`
SleepTime *string `json:"sleep_time"`
QuitDate *string `json:"quit_date"`
}
func (h *SmokeHandler) GetProfile(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
view, err := h.smokeProfileService.GetView(c.Request.Context(), int(user.ID))
if err != nil {
@@ -45,11 +45,7 @@ func (h *SmokeHandler) GetProfile(c *gin.Context) {
}
func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
var req upsertSmokeProfileRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
@@ -76,6 +72,21 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
}
}
quitDateProvided := false
var quitDate *time.Time
if req.QuitDate != nil {
quitDateProvided = true
value := strings.TrimSpace(*req.QuitDate)
if value != "" {
parsed, err := time.ParseInLocation(dateLayout, value, time.Local)
if err != nil {
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "quit_date 格式错误,应为 YYYY-MM-DD"))
return
}
quitDate = &parsed
}
}
view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{
BaselineCigsPerDay: req.BaselineCigsPerDay,
SmokingYears: req.SmokingYears,
@@ -84,6 +95,8 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
QuitMotivations: req.QuitMotivations,
WakeUpTime: req.WakeUpTime,
SleepTime: req.SleepTime,
QuitDateProvided: quitDateProvided,
QuitDate: quitDate,
})
if err != nil {
if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) {
@@ -96,4 +109,3 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
c.JSON(http.StatusOK, model.Success(view))
}
@@ -14,11 +14,7 @@ import (
)
func (h *SmokeHandler) Stats(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
rangeType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("range", "week")))
asOf := time.Now().In(time.Local)