diff --git a/cmd/api/main.go b/cmd/api/main.go index 74d79f4..8ae5b4c 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -60,6 +60,7 @@ func main() { &smokemodel.SmokeAIAdvice{}, &smokemodel.SmokeAIAdviceUnlock{}, &smokemodel.SmokeAINextSmoke{}, + &smokemodel.SmokeMotivationQuote{}, ); err != nil { log.Fatalf("auto migrate failed: %v", err) } diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 4d306a6..c7097dd 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -26,6 +26,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. smoke.GET("/logs/:id", smokeHandler.Get) smoke.POST("/logs/:id", smokeHandler.Update) smoke.DELETE("/logs/:id", smokeHandler.Delete) + smoke.GET("/motivation", smokeHandler.Motivation) // AI 戒烟建议(会员优先;非会员需看广告解锁) smoke.GET("/ai/advice", smokeHandler.GetAIAdvice) diff --git a/internal/smoke/handler/smoke_motivation_handler.go b/internal/smoke/handler/smoke_motivation_handler.go new file mode 100644 index 0000000..716eca2 --- /dev/null +++ b/internal/smoke/handler/smoke_motivation_handler.go @@ -0,0 +1,33 @@ +package handler + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +func (h *SmokeHandler) Motivation(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + profile, err := h.smokeProfileService.Get(c.Request.Context(), int(user.ID)) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试")) + return + } + + result, err := h.smokeLogService.Motivation(c.Request.Context(), int(user.ID), time.Now().In(time.Local), profile) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成激励语失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(result)) +} diff --git a/internal/smoke/model/smoke_motivation.go b/internal/smoke/model/smoke_motivation.go new file mode 100644 index 0000000..ca5280d --- /dev/null +++ b/internal/smoke/model/smoke_motivation.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SmokeMotivationQuote 存储不同场景的激励语模板(可附带 AI 提示词)。 +type SmokeMotivationQuote struct { + ID uint `gorm:"primaryKey;autoIncrement;comment:记录ID" json:"id"` + Scene string `gorm:"column:scene;size:40;index:idx_smoke_motivation_scene;comment:场景类型" json:"scene"` + Type string `gorm:"column:type;size:30;comment:文案类型" json:"type"` + Message string `gorm:"column:message;type:text;comment:展示文案" json:"message"` + AIPrompt string `gorm:"column:ai_prompt;type:text;comment:AI提示词" json:"ai_prompt,omitempty"` + Enabled bool `gorm:"column:enabled;default:true;comment:是否启用" json:"enabled"` + Weight int `gorm:"column:weight;default:1;comment:权重(越大越优先)" json:"weight"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` +} + +func (SmokeMotivationQuote) TableName() string { + return "fa_smoke_motivation_quote" +} + +func (SmokeMotivationQuote) TableComment() string { + return "戒烟-激励语模板" +} diff --git a/internal/smoke/service/smoke_motivation_service.go b/internal/smoke/service/smoke_motivation_service.go new file mode 100644 index 0000000..329c735 --- /dev/null +++ b/internal/smoke/service/smoke_motivation_service.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + + smokemodel "wx_service/internal/smoke/model" +) + +type SmokeMotivation struct { + Message string `json:"message"` + Type string `json:"type"` +} + +func (s *SmokeLogService) Motivation(ctx context.Context, uid int, asOf time.Time, profile *smokemodel.SmokeUserProfile) (SmokeMotivation, error) { + home, err := s.HomeSummary(ctx, uid, asOf) + if err != nil { + return SmokeMotivation{}, err + } + + minutesSinceLast := -1 + if home.LastSmokeAt != nil { + diff := int(asOf.Sub(*home.LastSmokeAt).Minutes()) + if diff < 0 { + diff = 0 + } + minutesSinceLast = diff + } + + dailyTarget := 0 + if profile != nil && profile.BaselineCigsPerDay > 0 { + dailyTarget = profile.BaselineCigsPerDay + } + + quitMotivation := "" + if profile != nil && len(profile.QuitMotivations) > 0 { + quitMotivation = profile.QuitMotivations[0] + } + + scene, fallback := buildMotivationScene(home, minutesSinceLast, dailyTarget, quitMotivation) + if quote, ok, err := s.loadMotivationQuote(ctx, scene); err != nil { + return fallback, err + } else if ok { + return SmokeMotivation{Message: quote.Message, Type: quote.Type}, nil + } + + if scene != "default" { + if quote, ok, err := s.loadMotivationQuote(ctx, "default"); err != nil { + return fallback, err + } else if ok { + return SmokeMotivation{Message: quote.Message, Type: quote.Type}, nil + } + } + + return fallback, nil +} + +func buildMotivationScene(home SmokeHomeSummary, minutesSinceLast int, dailyTarget int, quitMotivation string) (string, SmokeMotivation) { + if home.ResistedCount > 0 && minutesSinceLast >= 0 && minutesSinceLast < 30 { + return "recent_resist", SmokeMotivation{ + Message: "太棒了!你刚刚成功抵抗了一次烟瘾", + Type: "praise", + } + } + + if dailyTarget > 0 && home.TodayCount < int(float64(dailyTarget)*0.5) { + return "below_half_target", SmokeMotivation{ + Message: "今天的表现非常出色,继续保持!", + Type: "encourage", + } + } + + if dailyTarget > 0 && home.TodayCount == dailyTarget-1 { + return "near_limit", SmokeMotivation{ + Message: "还剩最后一支配额,考虑把它留到睡前?", + Type: "hint", + } + } + + if dailyTarget > 0 && home.TodayCount > dailyTarget { + msg := "没关系,明天是新的一天。" + if quitMotivation != "" { + msg = msg + "记住你为什么要戒烟:" + quitMotivation + } + return "over_target", SmokeMotivation{ + Message: msg, + Type: "comfort", + } + } + + return "default", SmokeMotivation{ + Message: "保持连胜纪录!", + Type: "encourage", + } +} + +func (s *SmokeLogService) loadMotivationQuote(ctx context.Context, scene string) (smokemodel.SmokeMotivationQuote, bool, error) { + var quote smokemodel.SmokeMotivationQuote + if err := s.db.WithContext(ctx). + Where("scene = ? AND enabled = 1", scene). + Order("weight DESC, id ASC"). + First("e).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return smokemodel.SmokeMotivationQuote{}, false, nil + } + return smokemodel.SmokeMotivationQuote{}, false, err + } + if quote.Message == "" || quote.Type == "" { + return smokemodel.SmokeMotivationQuote{}, false, nil + } + return quote, true, nil +}