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
@@ -27,11 +27,7 @@ type qiniuTokenRequest struct {
// QiniuToken 返回七牛直传所需的 token/key/upload_url 等信息。
// 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。
func (h *UploadHandler) QiniuToken(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 qiniuTokenRequest
_ = c.ShouldBindJSON(&req) // filename 可选,解析失败也不影响生成 token
@@ -7,9 +7,9 @@ import (
"github.com/gin-gonic/gin"
membershipservice "wx_service/internal/membership/service"
"wx_service/internal/middleware"
"wx_service/internal/model"
membershipservice "wx_service/internal/membership/service"
)
type RedeemCodeHandler struct {
@@ -80,11 +80,7 @@ type redeemRequest struct {
}
func (h *RedeemCodeHandler) Redeem(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 redeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
+33
View File
@@ -1,6 +1,8 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"wx_service/internal/model"
@@ -16,3 +18,34 @@ func CurrentUser(c *gin.Context) (*model.User, bool) {
user, ok := userVal.(*model.User)
return user, ok
}
// MustCurrentUser 仅用于已通过鉴权中间件的路由,避免每个 handler 重复判断。
func MustCurrentUser(c *gin.Context) *model.User {
userVal := c.MustGet(ContextCurrentUserKey)
user, ok := userVal.(*model.User)
if !ok || user == nil {
panic("current user missing in context")
}
return user
}
// RequireUser 是对 CurrentUser 的封装,复用统一的未登录响应。
func RequireUser(c *gin.Context) (*model.User, bool) {
user, ok := CurrentUser(c)
if ok {
return user, true
}
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return nil, false
}
// RequireUserMiddleware 将登录校验统一下沉到路由层。
func RequireUserMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := CurrentUser(c); !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
c.Next()
}
}
@@ -43,11 +43,7 @@ func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
return
}
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
result, err := h.videoService.RemoveWatermark(c.Request.Context(), user, req.Content)
if err != nil {
@@ -80,11 +76,7 @@ func (h *VideoHandler) RemoveWatermark(c *gin.Context) {
}
func (h *VideoHandler) UnlockQuota(c *gin.Context) {
user, ok := middleware.CurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
return
}
user := middleware.MustCurrentUser(c)
if err := h.videoService.UnlockForToday(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "解锁失败,请稍后重试"))
+1
View File
@@ -47,6 +47,7 @@ func Register(
// 需要登录的接口组:统一挂载鉴权中间件
protected := api.Group("")
protected.Use(middleware.AuthMiddleware(db, sessionCache))
protected.Use(middleware.RequireUserMiddleware())
{
registerCommonRoutes(protected, uploadHandler)
registerRemoveWatermarkRoutes(api, protected, videoHandler)
+2
View File
@@ -10,6 +10,8 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
// 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开)
smoke := protected.Group("/smoke")
{
smoke.GET("/home", smokeHandler.Home)
// 首次进入/基础信息(用于基准、AI 个性化、作息规避等)
smoke.GET("/profile", smokeHandler.GetProfile)
smoke.POST("/profile", smokeHandler.UpsertProfile)
+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)
+2
View File
@@ -71,6 +71,8 @@ type SmokeUserProfile struct {
WakeUpTime string `gorm:"column:wake_up_time;size:5;comment:起床时间(HH:MM)" json:"wake_up_time"`
SleepTime string `gorm:"column:sleep_time;size:5;comment:入睡时间(HH:MM)" json:"sleep_time"`
QuitDate *time.Time `gorm:"column:quit_date;type:date;comment:目标戒烟日期" json:"quit_date,omitempty"`
OnboardingCompletedAt *time.Time `gorm:"column:onboarding_completed_at;comment:首次补全完成时间" json:"onboarding_completed_at,omitempty"`
}
@@ -44,12 +44,12 @@ func NewSmokeAINextSmokeService(db *gorm.DB, cfg config.AIConfig) *SmokeAINextSm
}
type aiNextSmokeInput struct {
AsOf string `json:"as_of"`
PlanDate string `json:"plan_date"`
MinNotBeforeAt string `json:"min_not_before_at"`
AsOf string `json:"as_of"`
PlanDate string `json:"plan_date"`
MinNotBeforeAt string `json:"min_not_before_at"`
DefaultSuggestion NextSmokeSuggestion `json:"default_suggestion"`
Profile *adviceUserProfile `json:"profile,omitempty"`
Recent3Days []recentDaySnapshot `json:"recent_3_days"`
Profile *adviceUserProfile `json:"profile,omitempty"`
Recent3Days []recentDaySnapshot `json:"recent_3_days"`
}
type aiNextSmokeOutput struct {
@@ -75,14 +75,14 @@ type recentDayNode struct {
}
type AINextSmokeSuggestion 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"`
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"`
}
func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *usermodel.User, asOf time.Time, planDate time.Time, promptVersion string, defaultSuggestion NextSmokeSuggestion) (AINextSmokeSuggestion, error) {
@@ -122,12 +122,12 @@ func (s *SmokeAINextSmokeService) GetOrGenerate(ctx context.Context, user *userm
minNotBefore := s.computeMinNotBefore(asOf, planDate, defaultSuggestion, profile)
input := aiNextSmokeInput{
AsOf: asOf.In(time.Local).Format(time.RFC3339),
PlanDate: planDate.Format("2006-01-02"),
MinNotBeforeAt: minNotBefore.In(time.Local).Format(time.RFC3339),
DefaultSuggestion: defaultSuggestion,
Profile: profile,
Recent3Days: recent,
AsOf: asOf.In(time.Local).Format(time.RFC3339),
PlanDate: planDate.Format("2006-01-02"),
MinNotBeforeAt: minNotBefore.In(time.Local).Format(time.RFC3339),
DefaultSuggestion: defaultSuggestion,
Profile: profile,
Recent3Days: recent,
}
inputJSON, _ := json.Marshal(input)
@@ -402,7 +402,7 @@ func (s *SmokeAINextSmokeService) loadRecent3Days(ctx context.Context, uid int,
}
snap := ensure(day)
isResisted := l.Level == 0 && l.Num == 0
isResisted := l.Num == 0
if isResisted {
snap.ResistedCount++
} else if l.Num > 0 {
+34 -6
View File
@@ -95,6 +95,7 @@ type ListSmokeLogsRequest struct {
PageSize int
Start *time.Time
End *time.Time
Type string
}
type ListSmokeLogsResult struct {
@@ -124,6 +125,7 @@ type SmokeHomeSummary struct {
ResistedCount int
ReducedFromYesterday int
ExceededYesterday bool
SecondsSinceLast int
}
// DashboardWeeklyStat 表示某一天的抽烟支数以及是否为今天。
@@ -155,6 +157,14 @@ func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRe
if req.End != nil {
tx = tx.Where("smoke_time <= ?", req.End.Format("2006-01-02"))
}
switch req.Type {
case "", "all":
// no-op
case "smoke":
tx = tx.Where("num > 0")
case "resisted":
tx = tx.Where("num = 0")
}
var total int64
if err := tx.Count(&total).Error; err != nil {
@@ -164,7 +174,7 @@ func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRe
var items []smokemodel.SmokeLog
offset := (page - 1) * pageSize
if err := tx.
Order("smoke_time DESC").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
Order("id DESC").
Limit(pageSize).
Offset(offset).
@@ -221,7 +231,7 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb
var last smokemodel.SmokeLog
if err := s.db.WithContext(ctx).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("NOT (level = 0 AND num = 0)").
Where("num > 0").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
Order("id DESC").
Limit(1).
@@ -259,6 +269,7 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb
// HomeSummary 返回首页所需的汇总数据(不包含时间范围的周统计)。
func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Time) (SmokeHomeSummary, error) {
localAsOf := asOf.In(time.Local)
today := dateOnly(asOf)
todayKey := today.Format("2006-01-02")
yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02")
@@ -276,7 +287,7 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
if err := s.db.WithContext(ctx).
Model(&smokemodel.SmokeLog{}).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("level = 0 AND num = 0 AND smoke_time = ?", todayKey).
Where("num = 0 AND smoke_time = ?", todayKey).
Count(&resistedCount).Error; err != nil {
return SmokeHomeSummary{}, fmt.Errorf("count resisted logs: %w", err)
}
@@ -290,14 +301,22 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
return SmokeHomeSummary{}, fmt.Errorf("count yesterday smoke logs: %w", err)
}
reduced := int(yesterdayCount - todayCount)
exceeded := reduced < 0
diffFromYesterday := int(yesterdayCount - todayCount)
reduced := 0
exceeded := false
if diffFromYesterday > 0 {
reduced = diffFromYesterday
} else if diffFromYesterday < 0 {
reduced = -diffFromYesterday
exceeded = true
}
var lastSmokeAt *time.Time
secondsSinceLast := -1
var last smokemodel.SmokeLog
if err := s.db.WithContext(ctx).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("NOT (level = 0 AND num = 0)").
Where("num > 0").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
Order("id DESC").
Limit(1).
@@ -306,7 +325,15 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
return SmokeHomeSummary{}, fmt.Errorf("load last smoke log: %w", err)
}
} else if t, ok := lastEventTime(last); ok {
if t.After(localAsOf) {
t = localAsOf
}
lastSmokeAt = &t
diff := int(localAsOf.Sub(t).Seconds())
if diff < 0 {
diff = 0
}
secondsSinceLast = diff
}
return SmokeHomeSummary{
@@ -315,6 +342,7 @@ func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Ti
ResistedCount: int(resistedCount),
ReducedFromYesterday: reduced,
ExceededYesterday: exceeded,
SecondsSinceLast: secondsSinceLast,
}, nil
}
+34 -16
View File
@@ -20,21 +20,22 @@ func NewSmokeNextService(db *gorm.DB) *SmokeNextService {
}
type NextSmokeSuggestion struct {
LastSmokeAt *time.Time `json:"last_smoke_at,omitempty"`
NextSmokeAt *time.Time `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"`
LastSmokeAt *time.Time `json:"last_smoke_at,omitempty"`
NextSmokeAt *time.Time `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"`
}
// GetDefaultSuggestion 返回“未使用 AI 时”的默认下次抽烟时间建议(阶梯式延时算法)。
func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, asOf time.Time, planDate time.Time, profileView SmokeProfileView) (NextSmokeSuggestion, error) {
now := asOf.In(time.Local)
planDay := dateOnly(planDate)
today := dateOnly(now)
base := profileView.BaselineIntervalMinute
if base <= 0 {
@@ -46,8 +47,11 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
return NextSmokeSuggestion{}, err
}
if !ok {
nowCopy := now
lastSmokeAt = &nowCopy
lastCopy := now
lastSmokeAt = &lastCopy
} else if lastSmokeAt.After(now) {
clamped := now
lastSmokeAt = &clamped
}
resisted, err := s.countResistedLastDays(ctx, uid, 7)
@@ -68,7 +72,22 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
interval = 240
}
next := lastSmokeAt.Add(time.Duration(interval) * time.Minute)
intervalDuration := time.Duration(interval) * time.Minute
next := lastSmokeAt.Add(intervalDuration)
if !planDay.After(today) && next.Before(now) {
if intervalDuration <= 0 {
next = now
} else {
elapsed := now.Sub(*lastSmokeAt)
missed := int(elapsed / intervalDuration)
if missed < 0 {
missed = 0
}
next = lastSmokeAt.Add(time.Duration(missed+1) * intervalDuration)
}
}
sleepAdjusted := false
var wakeUp, sleep string
@@ -78,7 +97,6 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
}
// 如果是“生成某一天的计划”(例如明天),默认不早于该日的起床时间(若未配置则使用 07:00)。
today := dateOnly(now)
if planDay.After(today) {
minNotBefore := time.Date(planDay.Year(), planDay.Month(), planDay.Day(), 7, 0, 0, 0, time.Local)
if wakeUp != "" {
@@ -105,7 +123,7 @@ func (s *SmokeNextService) GetDefaultSuggestion(ctx context.Context, uid int, as
out := NextSmokeSuggestion{
BaseIntervalMinutes: base,
IntervalMinutes: interval,
Stage: stage,
Stage: stage,
Resisted7d: resisted,
SleepAdjusted: sleepAdjusted,
Algorithm: "staircase_delay_v1",
@@ -123,7 +141,7 @@ func (s *SmokeNextService) loadLastActualSmokeAt(ctx context.Context, uid int) (
var last smokemodel.SmokeLog
err := s.db.WithContext(ctx).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("NOT (level = 0 AND num = 0)").
Where("num > 0").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
Order("id DESC").
Limit(1).
@@ -152,7 +170,7 @@ func (s *SmokeNextService) countResistedLastDays(ctx context.Context, uid int, d
if err := s.db.WithContext(ctx).
Model(&smokemodel.SmokeLog{}).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("level = 0 AND num = 0").
Where("num = 0").
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("count resisted logs: %w", err)
@@ -25,7 +25,7 @@ func NewSmokeProfileService(db *gorm.DB) *SmokeProfileService {
}
type SmokeProfileView struct {
Exists bool `json:"exists"`
Exists bool `json:"exists"`
Profile *smokemodel.SmokeUserProfile `json:"profile,omitempty"`
IsCompleted bool `json:"is_completed"`
@@ -89,6 +89,9 @@ type UpsertSmokeProfileRequest struct {
WakeUpTime *string
SleepTime *string
QuitDateProvided bool
QuitDate *time.Time
}
func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) {
@@ -146,6 +149,9 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
if err := applyTimeStr(&profile.SleepTime, req.SleepTime); err != nil {
return SmokeProfileView{}, err
}
if req.QuitDateProvided {
profile.QuitDate = req.QuitDate
}
now := time.Now()
if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) {
@@ -195,7 +201,7 @@ func awakeMinutesWithFallback(wakeUp, sleep string) (int, error) {
if sleepMin > wakeMin {
return sleepMin - wakeMin, nil
}
return (24 * 60 - wakeMin) + sleepMin, nil
return (24*60 - wakeMin) + sleepMin, nil
}
func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int {
+63 -6
View File
@@ -83,6 +83,7 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
if err != nil {
return SmokeStatsResult{}, err
}
trend = limitTrend(trend, 7)
dayCount := daysBetweenInclusive(start, end)
dailyAvg := 0
@@ -109,7 +110,7 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
return SmokeStatsResult{}, err
}
money := s.computeMoney(profile, int(total), dayCount)
money := s.computeMoney(ctx, uid, profile, int(total), start, end)
health, err := s.computeHealth(ctx, uid, req.AsOf)
if err != nil {
return SmokeStatsResult{}, err
@@ -130,6 +131,24 @@ func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequ
}, nil
}
func limitTrend(items []SmokeStatsTrend, max int) []SmokeStatsTrend {
if max <= 0 || len(items) <= max {
return items
}
lastIndex := len(items) - 1
out := make([]SmokeStatsTrend, 0, max)
seen := make(map[int]struct{}, max)
for i := 0; i < max; i++ {
pos := int(math.Round(float64(i) * float64(lastIndex) / float64(max-1)))
if _, ok := seen[pos]; ok {
continue
}
seen[pos] = struct{}{}
out = append(out, items[pos])
}
return out
}
func (s *SmokeLogService) loadDailyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) {
type dailyCount struct {
SmokeTime time.Time `gorm:"column:smoke_time"`
@@ -225,7 +244,7 @@ func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end
if err := s.db.WithContext(ctx).
Model(&smokemodel.SmokeLog{}).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("level = 0 AND num = 0").
Where("num = 0").
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("count resisted: %w", err)
@@ -267,12 +286,31 @@ func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf t
return streak, nil
}
func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, actualTotal int, dayCount int) SmokeStatsMoney {
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 || dayCount <= 0 {
func (s *SmokeLogService) computeMoney(ctx context.Context, uid int, profile *smokemodel.SmokeUserProfile, actualTotal int, start, end time.Time) SmokeStatsMoney {
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 {
return SmokeStatsMoney{Available: false}
}
expectedTotal := profile.BaselineCigsPerDay * dayCount
activeDays, err := s.countLogDays(ctx, uid, start, end)
if err != nil {
return SmokeStatsMoney{Available: false}
}
if activeDays <= 0 {
return SmokeStatsMoney{
Available: true,
PackPriceCent: profile.PackPriceCent,
CigsPerPack: defaultCigsPerPack,
ExpectedTotal: 0,
ActualTotal: actualTotal,
SavedCent: 0,
}
}
expectedTotal := profile.BaselineCigsPerDay * activeDays
savedCigs := expectedTotal - actualTotal
if savedCigs < 0 {
savedCigs = 0
}
savedPacks := float64(savedCigs) / float64(defaultCigsPerPack)
savedCent := int(math.Round(savedPacks * float64(profile.PackPriceCent)))
return SmokeStatsMoney{
@@ -285,6 +323,25 @@ func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, act
}
}
func (s *SmokeLogService) countLogDays(ctx context.Context, uid int, start, end time.Time) (int, error) {
start = dateOnly(start)
end = dateOnly(end)
type row struct {
SmokeTime time.Time `gorm:"column:smoke_time"`
}
var rows []row
if err := s.db.WithContext(ctx).
Model(&smokemodel.SmokeLog{}).
Distinct("smoke_time").
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
Find(&rows).Error; err != nil {
return 0, fmt.Errorf("count log days: %w", err)
}
return len(rows), nil
}
func (s *SmokeLogService) computeHealth(ctx context.Context, uid int, asOf time.Time) (SmokeStatsHealth, error) {
lastSmokeAt, err := s.loadLastActualSmokeAt(ctx, uid)
if err != nil {
@@ -311,7 +368,7 @@ func (s *SmokeLogService) loadLastActualSmokeAt(ctx context.Context, uid int) (*
var last smokemodel.SmokeLog
if err := s.db.WithContext(ctx).
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
Where("NOT (level = 0 AND num = 0)").
Where("num > 0").
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
Order("id DESC").
Limit(1).