From a4b466744c299743aa618f44a442c8eb0a0e9ade Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Mar 2026 17:29:36 +0800 Subject: [PATCH] feat(admin): add fa_smoke CRUD APIs with Chinese comments (#45) --- internal/admin/handler/smoke_handler.go | 962 ++++++++++++++++++++ internal/admin/service/smoke_service.go | 1068 +++++++++++++++++++++++ internal/admin/service/types.go | 6 + internal/routes/admin_routes.go | 37 + 4 files changed, 2073 insertions(+) create mode 100644 internal/admin/handler/smoke_handler.go create mode 100644 internal/admin/service/smoke_service.go diff --git a/internal/admin/handler/smoke_handler.go b/internal/admin/handler/smoke_handler.go new file mode 100644 index 0000000..cb9c1c6 --- /dev/null +++ b/internal/admin/handler/smoke_handler.go @@ -0,0 +1,962 @@ +package handler + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" + smokemodel "wx_service/internal/smoke/model" +) + +// ===== 戒烟记录(fa_smoke_log) ===== + +type smokeLogListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + DateFrom string `form:"date_from"` + DateTo string `form:"date_to"` +} + +type smokeLogUpsertRequest struct { + UID *int `json:"uid"` + SmokeTime *string `json:"smoke_time"` + SmokeAt *string `json:"smoke_at"` + Remark *string `json:"remark"` + Level *int64 `json:"level"` + Num *int `json:"num"` +} + +func (h *Handler) ListSmokeLogs(c *gin.Context) { + var query smokeLogListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + dateFrom, err := parseDateOnly(query.DateFrom) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_from, expected YYYY-MM-DD")) + return + } + dateTo, err := parseDateOnly(query.DateTo) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_to, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListSmokeLogs(c.Request.Context(), adminservice.ListSmokeLogsQuery{ + Page: query.Page, + PageSize: query.PageSize, + UID: query.UID, + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke logs failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeLog(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + + data, err := h.svc.GetSmokeLog(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeLogNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke log not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke log failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeLog(c *gin.Context) { + var req smokeLogUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + + input, err := buildSmokeLogInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + + data, err := h.svc.CreateSmokeLog(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "uid is required")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke log failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeLog(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + + var req smokeLogUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeLogInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + + data, err := h.svc.UpdateSmokeLog(c.Request.Context(), id, input) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrSmokeLogNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke log not found")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid input")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke log failed")) + } + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeLog(c *gin.Context) { + id, err := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeLog(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeLogNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke log not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke log failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +func buildSmokeLogInput(req smokeLogUpsertRequest) (adminservice.SmokeLogUpsertInput, error) { + input := adminservice.SmokeLogUpsertInput{ + UID: req.UID, + Remark: req.Remark, + Level: req.Level, + Num: req.Num, + } + if req.SmokeTime != nil { + parsed, err := parseDateOnlyRequired(*req.SmokeTime) + if err != nil { + return input, errors.New("invalid smoke_time, expected YYYY-MM-DD") + } + value := &parsed + input.SmokeTime = &value + } + if req.SmokeAt != nil { + parsed, err := parseDatetimeRequired(*req.SmokeAt) + if err != nil { + return input, errors.New("invalid smoke_at, expected YYYY-MM-DD HH:mm:ss") + } + value := &parsed + input.SmokeAt = &value + } + return input, nil +} + +// ===== 用户画像(fa_smoke_user_profile) ===== + +type smokeProfileListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` +} + +type smokeProfileUpsertRequest struct { + UID *int `json:"uid"` + BaselineCigsPerDay *int `json:"baseline_cigs_per_day"` + SmokingYears *float64 `json:"smoking_years"` + PackPriceCent *int `json:"pack_price_cent"` + SmokeMotivations []string `json:"smoke_motivations"` + QuitMotivations []string `json:"quit_motivations"` + WakeUpTime *string `json:"wake_up_time"` + SleepTime *string `json:"sleep_time"` + QuitDate *string `json:"quit_date"` + OnboardingCompletedAt *string `json:"onboarding_completed_at"` +} + +func (h *Handler) ListSmokeProfiles(c *gin.Context) { + var query smokeProfileListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + data, err := h.svc.ListSmokeProfiles(c.Request.Context(), adminservice.ListSmokeProfilesQuery{Page: query.Page, PageSize: query.PageSize, UID: query.UID}) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke profiles failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeProfile(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + data, err := h.svc.GetSmokeProfile(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeProfileNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke profile not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke profile failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeProfile(c *gin.Context) { + var req smokeProfileUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeProfileInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.CreateSmokeProfile(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "uid is required or duplicated")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke profile failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeProfile(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req smokeProfileUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeProfileInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.UpdateSmokeProfile(c.Request.Context(), id, input) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeProfileNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke profile not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke profile failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeProfile(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeProfile(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeProfileNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke profile not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke profile failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +func buildSmokeProfileInput(req smokeProfileUpsertRequest) (adminservice.SmokeProfileUpsertInput, error) { + input := adminservice.SmokeProfileUpsertInput{ + UID: req.UID, + BaselineCigsPerDay: req.BaselineCigsPerDay, + SmokingYears: req.SmokingYears, + PackPriceCent: req.PackPriceCent, + WakeUpTime: req.WakeUpTime, + SleepTime: req.SleepTime, + } + + if req.SmokeMotivations != nil { + values := smokemodel.StringSlice(req.SmokeMotivations) + input.SmokeMotivations = &values + } + if req.QuitMotivations != nil { + values := smokemodel.StringSlice(req.QuitMotivations) + input.QuitMotivations = &values + } + if req.QuitDate != nil { + parsed, err := parseDateOnlyRequired(*req.QuitDate) + if err != nil { + return input, errors.New("invalid quit_date, expected YYYY-MM-DD") + } + value := &parsed + input.QuitDate = &value + } + if req.OnboardingCompletedAt != nil { + parsed, err := parseDatetimeRequired(*req.OnboardingCompletedAt) + if err != nil { + return input, errors.New("invalid onboarding_completed_at, expected YYYY-MM-DD HH:mm:ss") + } + value := &parsed + input.OnboardingCompletedAt = &value + } + + return input, nil +} + +// ===== AI 建议(fa_smoke_ai_advice) ===== + +type smokeAIAdviceListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + Type string `form:"type"` + AdviceDate string `form:"advice_date"` +} + +type smokeAIAdviceUpsertRequest struct { + UID *int `json:"uid"` + Type *string `json:"type"` + AdviceDate *string `json:"advice_date"` + PromptVersion *string `json:"prompt_version"` + Provider *string `json:"provider"` + Model *string `json:"model"` + InputSnapshot *string `json:"input_snapshot"` + Advice *string `json:"advice"` + TokensIn *int `json:"tokens_in"` + TokensOut *int `json:"tokens_out"` + CostCent *int `json:"cost_cent"` +} + +func (h *Handler) ListSmokeAIAdvices(c *gin.Context) { + var query smokeAIAdviceListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + adviceDate, err := parseDateOnly(query.AdviceDate) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid advice_date, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListSmokeAIAdvices(c.Request.Context(), adminservice.ListSmokeAIAdvicesQuery{Page: query.Page, PageSize: query.PageSize, UID: query.UID, Type: query.Type, AdviceDate: adviceDate}) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai advices failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeAIAdvice(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + data, err := h.svc.GetSmokeAIAdvice(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeAIAdviceNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai advice not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai advice failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeAIAdvice(c *gin.Context) { + var req smokeAIAdviceUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAIAdviceInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.CreateSmokeAIAdvice(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "uid/advice/advice_date are required")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke ai advice failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeAIAdvice(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req smokeAIAdviceUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAIAdviceInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.UpdateSmokeAIAdvice(c.Request.Context(), id, input) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeAIAdviceNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai advice not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke ai advice failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeAIAdvice(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeAIAdvice(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeAIAdviceNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai advice not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke ai advice failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +func buildSmokeAIAdviceInput(req smokeAIAdviceUpsertRequest) (adminservice.SmokeAIAdviceUpsertInput, error) { + input := adminservice.SmokeAIAdviceUpsertInput{ + UID: req.UID, + Type: req.Type, + PromptVersion: req.PromptVersion, + Provider: req.Provider, + Model: req.Model, + InputSnapshot: req.InputSnapshot, + Advice: req.Advice, + TokensIn: req.TokensIn, + TokensOut: req.TokensOut, + CostCent: req.CostCent, + } + if req.AdviceDate != nil { + parsed, err := parseDateOnlyRequired(*req.AdviceDate) + if err != nil { + return input, errors.New("invalid advice_date, expected YYYY-MM-DD") + } + input.AdviceDate = &parsed + } + return input, nil +} + +// ===== AI 解锁(fa_smoke_ai_advice_unlocks) ===== + +type smokeAIUnlockListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + UnlockDate string `form:"unlock_date"` +} + +type smokeAIUnlockUpsertRequest struct { + UID *int `json:"uid"` + UnlockDate *string `json:"unlock_date"` + AdWatchedAt *string `json:"ad_watched_at"` +} + +func (h *Handler) ListSmokeAIUnlocks(c *gin.Context) { + var query smokeAIUnlockListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + unlockDate, err := parseDateOnly(query.UnlockDate) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid unlock_date, expected YYYY-MM-DD")) + return + } + data, err := h.svc.ListSmokeAIUnlocks(c.Request.Context(), adminservice.ListSmokeAIUnlocksQuery{Page: query.Page, PageSize: query.PageSize, UID: query.UID, UnlockDate: unlockDate}) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai unlocks failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeAIUnlock(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + data, err := h.svc.GetSmokeAIUnlock(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeAIUnlockNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai unlock not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai unlock failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeAIUnlock(c *gin.Context) { + var req smokeAIUnlockUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAIUnlockInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.CreateSmokeAIUnlock(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "uid/unlock_date/ad_watched_at are required")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke ai unlock failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeAIUnlock(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req smokeAIUnlockUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAIUnlockInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.UpdateSmokeAIUnlock(c.Request.Context(), id, input) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeAIUnlockNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai unlock not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke ai unlock failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeAIUnlock(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeAIUnlock(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeAIUnlockNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai unlock not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke ai unlock failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +func buildSmokeAIUnlockInput(req smokeAIUnlockUpsertRequest) (adminservice.SmokeAIUnlockUpsertInput, error) { + input := adminservice.SmokeAIUnlockUpsertInput{UID: req.UID} + if req.UnlockDate != nil { + parsed, err := parseDateOnlyRequired(*req.UnlockDate) + if err != nil { + return input, errors.New("invalid unlock_date, expected YYYY-MM-DD") + } + input.UnlockDate = &parsed + } + if req.AdWatchedAt != nil { + parsed, err := parseDatetimeRequired(*req.AdWatchedAt) + if err != nil { + return input, errors.New("invalid ad_watched_at, expected YYYY-MM-DD HH:mm:ss") + } + input.AdWatchedAt = &parsed + } + return input, nil +} + +// ===== AI 下次抽烟节点(fa_smoke_ai_next_smoke) ===== + +type smokeAINextListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + PlanDate string `form:"plan_date"` +} + +type smokeAINextUpsertRequest struct { + UID *int `json:"uid"` + PlanDate *string `json:"plan_date"` + AIAdviceID *uint `json:"ai_advice_id"` + NodeType *string `json:"node_type"` + NodeAt *string `json:"node_at"` +} + +func (h *Handler) ListSmokeAINexts(c *gin.Context) { + var query smokeAINextListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + planDate, err := parseDateOnly(query.PlanDate) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid plan_date, expected YYYY-MM-DD")) + return + } + data, err := h.svc.ListSmokeAINexts(c.Request.Context(), adminservice.ListSmokeAINextsQuery{Page: query.Page, PageSize: query.PageSize, UID: query.UID, PlanDate: planDate}) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai next nodes failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeAINext(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + data, err := h.svc.GetSmokeAINext(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeAINextNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai next node not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke ai next node failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeAINext(c *gin.Context) { + var req smokeAINextUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAINextInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.CreateSmokeAINext(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "uid/plan_date/ai_advice_id/node_type/node_at are required")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke ai next node failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeAINext(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req smokeAINextUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input, err := buildSmokeAINextInput(req) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + data, err := h.svc.UpdateSmokeAINext(c.Request.Context(), id, input) + if err != nil { + switch { + case errors.Is(err, adminservice.ErrSmokeAINextNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai next node not found")) + case errors.Is(err, adminservice.ErrInvalidInput): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid input")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke ai next node failed")) + } + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeAINext(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeAINext(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeAINextNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke ai next node not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke ai next node failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +func buildSmokeAINextInput(req smokeAINextUpsertRequest) (adminservice.SmokeAINextUpsertInput, error) { + input := adminservice.SmokeAINextUpsertInput{UID: req.UID, AIAdviceID: req.AIAdviceID, NodeType: req.NodeType} + if req.PlanDate != nil { + parsed, err := parseDateOnlyRequired(*req.PlanDate) + if err != nil { + return input, errors.New("invalid plan_date, expected YYYY-MM-DD") + } + input.PlanDate = &parsed + } + if req.NodeAt != nil { + parsed, err := parseDatetimeRequired(*req.NodeAt) + if err != nil { + return input, errors.New("invalid node_at, expected YYYY-MM-DD HH:mm:ss") + } + input.NodeAt = &parsed + } + return input, nil +} + +// ===== 激励语模板(fa_smoke_motivation_quote) ===== + +type smokeMotivationListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Scene string `form:"scene"` + Type string `form:"type"` + Enabled string `form:"enabled"` +} + +type smokeMotivationUpsertRequest struct { + Scene *string `json:"scene"` + Type *string `json:"type"` + Message *string `json:"message"` + AIPrompt *string `json:"ai_prompt"` + Enabled *bool `json:"enabled"` + Weight *int `json:"weight"` +} + +func (h *Handler) ListSmokeMotivations(c *gin.Context) { + var query smokeMotivationListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + var enabled *bool + if strings.TrimSpace(query.Enabled) != "" { + parsed, err := parseOptionalBool(query.Enabled) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid enabled, expected true/false/1/0")) + return + } + enabled = parsed + } + data, err := h.svc.ListSmokeMotivations(c.Request.Context(), adminservice.ListSmokeMotivationsQuery{ + Page: query.Page, PageSize: query.PageSize, Scene: query.Scene, Type: query.Type, Enabled: enabled, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke motivations failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) GetSmokeMotivation(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + data, err := h.svc.GetSmokeMotivation(c.Request.Context(), id) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeMotivationNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke motivation not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load smoke motivation failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) CreateSmokeMotivation(c *gin.Context) { + var req smokeMotivationUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input := adminservice.SmokeMotivationUpsertInput{Scene: req.Scene, Type: req.Type, Message: req.Message, AIPrompt: req.AIPrompt, Enabled: req.Enabled, Weight: req.Weight} + data, err := h.svc.CreateSmokeMotivation(c.Request.Context(), input) + if err != nil { + if errors.Is(err, adminservice.ErrInvalidInput) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "scene/type/message are required")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "create smoke motivation failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) UpdateSmokeMotivation(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req smokeMotivationUpsertRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid request payload")) + return + } + input := adminservice.SmokeMotivationUpsertInput{Scene: req.Scene, Type: req.Type, Message: req.Message, AIPrompt: req.AIPrompt, Enabled: req.Enabled, Weight: req.Weight} + data, err := h.svc.UpdateSmokeMotivation(c.Request.Context(), id, input) + if err != nil { + if errors.Is(err, adminservice.ErrSmokeMotivationNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke motivation not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "update smoke motivation failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +func (h *Handler) DeleteSmokeMotivation(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteSmokeMotivation(c.Request.Context(), id); err != nil { + if errors.Is(err, adminservice.ErrSmokeMotivationNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "smoke motivation not found")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "delete smoke motivation failed")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"message": "删除成功"})) +} + +// ===== 时间解析辅助函数 ===== + +func parseDateOnly(raw string) (*time.Time, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, nil + } + parsed, err := parseDateOnlyRequired(text) + if err != nil { + return nil, err + } + return &parsed, nil +} + +func parseDateOnlyRequired(raw string) (time.Time, error) { + return time.ParseInLocation("2006-01-02", strings.TrimSpace(raw), time.Local) +} + +func parseDatetimeRequired(raw string) (time.Time, error) { + return time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(raw), time.Local) +} diff --git a/internal/admin/service/smoke_service.go b/internal/admin/service/smoke_service.go new file mode 100644 index 0000000..b0d87ba --- /dev/null +++ b/internal/admin/service/smoke_service.go @@ -0,0 +1,1068 @@ +package service + +import ( + "context" + "errors" + "strings" + "time" + + smokemodel "wx_service/internal/smoke/model" + + "gorm.io/gorm" +) + +// ===== 戒烟记录(fa_smoke_log) ===== + +// ListSmokeLogsQuery 定义戒烟记录列表查询参数。 +// 说明:后台列表默认按 id 倒序,支持按 uid 与日期区间过滤。 +type ListSmokeLogsQuery struct { + Page int + PageSize int + UID int + DateFrom *time.Time + DateTo *time.Time +} + +type SmokeLogItem struct { + ID int `json:"id"` + UID int `json:"uid"` + SmokeTime *time.Time `json:"smoke_time,omitempty"` + SmokeAt *time.Time `json:"smoke_at,omitempty"` + Remark string `json:"remark"` + Level int64 `json:"level"` + Num int `json:"num"` + CreateTime *int64 `json:"createtime,omitempty"` + UpdateTime *int64 `json:"updatetime,omitempty"` +} + +type ListSmokeLogsResult struct { + List []SmokeLogItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SmokeLogUpsertInput 用于新增与更新戒烟记录。 +// 说明:更新时可只传部分字段(指针字段支持局部更新)。 +type SmokeLogUpsertInput struct { + UID *int + SmokeTime **time.Time + SmokeAt **time.Time + Remark *string + Level *int64 + Num *int +} + +func (s *Service) ListSmokeLogs(ctx context.Context, query ListSmokeLogsQuery) (*ListSmokeLogsResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + + dbQuery := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("deletetime IS NULL OR deletetime = 0") + + if query.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", query.UID) + } + if query.DateFrom != nil { + dbQuery = dbQuery.Where("smoke_time >= ?", query.DateFrom.Format("2006-01-02")) + } + if query.DateTo != nil { + dbQuery = dbQuery.Where("smoke_time <= ?", query.DateTo.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]smokemodel.SmokeLog, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + list := make([]SmokeLogItem, 0, len(rows)) + for _, row := range rows { + list = append(list, SmokeLogItem{ + ID: row.ID, + UID: row.UID, + SmokeTime: row.SmokeTime, + SmokeAt: row.SmokeAt, + Remark: row.Remark, + Level: row.Level, + Num: row.Num, + CreateTime: row.CreateTime, + UpdateTime: row.UpdateTime, + }) + } + + return &ListSmokeLogsResult{ + List: list, + Total: total, + Page: query.Page, + PageSize: query.PageSize, + }, nil +} + +func (s *Service) GetSmokeLog(ctx context.Context, id int) (*SmokeLogItem, error) { + var row smokemodel.SmokeLog + if err := s.db.WithContext(ctx). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeLogNotFound + } + return nil, err + } + + return &SmokeLogItem{ + ID: row.ID, + UID: row.UID, + SmokeTime: row.SmokeTime, + SmokeAt: row.SmokeAt, + Remark: row.Remark, + Level: row.Level, + Num: row.Num, + CreateTime: row.CreateTime, + UpdateTime: row.UpdateTime, + }, nil +} + +func (s *Service) CreateSmokeLog(ctx context.Context, input SmokeLogUpsertInput) (*SmokeLogItem, error) { + if input.UID == nil || *input.UID <= 0 { + return nil, ErrInvalidInput + } + + level := int64(1) + if input.Level != nil && *input.Level > 0 { + level = *input.Level + } + num := 1 + if input.Num != nil && *input.Num > 0 { + num = *input.Num + } + + nowUnix := nowUnixPtr() + row := &smokemodel.SmokeLog{ + UID: *input.UID, + Level: level, + Num: num, + CreateTime: nowUnix, + UpdateTime: nowUnix, + } + if input.SmokeTime != nil { + row.SmokeTime = *input.SmokeTime + } + if input.SmokeAt != nil { + row.SmokeAt = *input.SmokeAt + } + if input.Remark != nil { + row.Remark = strings.TrimSpace(*input.Remark) + } + + if err := s.db.WithContext(ctx).Create(row).Error; err != nil { + return nil, err + } + + return s.GetSmokeLog(ctx, row.ID) +} + +func (s *Service) UpdateSmokeLog(ctx context.Context, id int, input SmokeLogUpsertInput) (*SmokeLogItem, error) { + updates := map[string]interface{}{ + "updatetime": time.Now().Unix(), + } + + if input.UID != nil && *input.UID > 0 { + updates["uid"] = *input.UID + } + if input.SmokeTime != nil { + updates["smoke_time"] = *input.SmokeTime + } + if input.SmokeAt != nil { + updates["smoke_at"] = *input.SmokeAt + } + if input.Remark != nil { + updates["remark"] = strings.TrimSpace(*input.Remark) + } + if input.Level != nil { + if *input.Level <= 0 { + return nil, ErrInvalidInput + } + updates["level"] = *input.Level + } + if input.Num != nil { + if *input.Num <= 0 { + return nil, ErrInvalidInput + } + updates["num"] = *input.Num + } + + result := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(updates) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrSmokeLogNotFound + } + + return s.GetSmokeLog(ctx, id) +} + +func (s *Service) DeleteSmokeLog(ctx context.Context, id int) error { + nowUnix := time.Now().Unix() + result := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(map[string]interface{}{ + "deletetime": nowUnix, + "updatetime": nowUnix, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeLogNotFound + } + return nil +} + +// ===== 用户画像(fa_smoke_user_profile) ===== + +type ListSmokeProfilesQuery struct { + Page int + PageSize int + UID int +} + +type SmokeProfileItem struct { + ID uint `json:"id"` + UID int `json:"uid"` + BaselineCigsPerDay int `json:"baseline_cigs_per_day"` + SmokingYears float64 `json:"smoking_years"` + PackPriceCent int `json:"pack_price_cent"` + SmokeMotivations smokemodel.StringSlice `json:"smoke_motivations"` + QuitMotivations smokemodel.StringSlice `json:"quit_motivations"` + WakeUpTime string `json:"wake_up_time"` + SleepTime string `json:"sleep_time"` + QuitDate *time.Time `json:"quit_date,omitempty"` + OnboardingCompletedAt *time.Time `json:"onboarding_completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListSmokeProfilesResult struct { + List []SmokeProfileItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SmokeProfileUpsertInput struct { + UID *int + BaselineCigsPerDay *int + SmokingYears *float64 + PackPriceCent *int + SmokeMotivations *smokemodel.StringSlice + QuitMotivations *smokemodel.StringSlice + WakeUpTime *string + SleepTime *string + QuitDate **time.Time + OnboardingCompletedAt **time.Time +} + +func (s *Service) ListSmokeProfiles(ctx context.Context, query ListSmokeProfilesQuery) (*ListSmokeProfilesResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + + dbQuery := s.db.WithContext(ctx).Model(&smokemodel.SmokeUserProfile{}) + if query.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", query.UID) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]smokemodel.SmokeUserProfile, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + list := make([]SmokeProfileItem, 0, len(rows)) + for _, row := range rows { + list = append(list, convertSmokeProfile(row)) + } + + return &ListSmokeProfilesResult{List: list, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (s *Service) GetSmokeProfile(ctx context.Context, id uint) (*SmokeProfileItem, error) { + var row smokemodel.SmokeUserProfile + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeProfileNotFound + } + return nil, err + } + item := convertSmokeProfile(row) + return &item, nil +} + +func (s *Service) CreateSmokeProfile(ctx context.Context, input SmokeProfileUpsertInput) (*SmokeProfileItem, error) { + if input.UID == nil || *input.UID <= 0 { + return nil, ErrInvalidInput + } + row := smokemodel.SmokeUserProfile{UID: *input.UID} + applySmokeProfileInput(&row, input) + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrInvalidInput + } + return nil, err + } + return s.GetSmokeProfile(ctx, row.ID) +} + +func (s *Service) UpdateSmokeProfile(ctx context.Context, id uint, input SmokeProfileUpsertInput) (*SmokeProfileItem, error) { + var row smokemodel.SmokeUserProfile + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeProfileNotFound + } + return nil, err + } + applySmokeProfileInput(&row, input) + if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + return s.GetSmokeProfile(ctx, id) +} + +func (s *Service) DeleteSmokeProfile(ctx context.Context, id uint) error { + result := s.db.WithContext(ctx).Delete(&smokemodel.SmokeUserProfile{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeProfileNotFound + } + return nil +} + +func convertSmokeProfile(row smokemodel.SmokeUserProfile) SmokeProfileItem { + return SmokeProfileItem{ + ID: row.ID, + UID: row.UID, + BaselineCigsPerDay: row.BaselineCigsPerDay, + SmokingYears: row.SmokingYears, + PackPriceCent: row.PackPriceCent, + SmokeMotivations: row.SmokeMotivations, + QuitMotivations: row.QuitMotivations, + WakeUpTime: row.WakeUpTime, + SleepTime: row.SleepTime, + QuitDate: row.QuitDate, + OnboardingCompletedAt: row.OnboardingCompletedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func applySmokeProfileInput(row *smokemodel.SmokeUserProfile, input SmokeProfileUpsertInput) { + if input.UID != nil && *input.UID > 0 { + row.UID = *input.UID + } + if input.BaselineCigsPerDay != nil { + row.BaselineCigsPerDay = *input.BaselineCigsPerDay + } + if input.SmokingYears != nil { + row.SmokingYears = *input.SmokingYears + } + if input.PackPriceCent != nil { + row.PackPriceCent = *input.PackPriceCent + } + if input.SmokeMotivations != nil { + row.SmokeMotivations = *input.SmokeMotivations + } + if input.QuitMotivations != nil { + row.QuitMotivations = *input.QuitMotivations + } + if input.WakeUpTime != nil { + row.WakeUpTime = strings.TrimSpace(*input.WakeUpTime) + } + if input.SleepTime != nil { + row.SleepTime = strings.TrimSpace(*input.SleepTime) + } + if input.QuitDate != nil { + row.QuitDate = *input.QuitDate + } + if input.OnboardingCompletedAt != nil { + row.OnboardingCompletedAt = *input.OnboardingCompletedAt + } +} + +// ===== AI 建议(fa_smoke_ai_advice) ===== + +type ListSmokeAIAdvicesQuery struct { + Page int + PageSize int + UID int + Type string + AdviceDate *time.Time +} + +type SmokeAIAdviceItem struct { + ID uint `json:"id"` + UID int `json:"uid"` + Type string `json:"type"` + AdviceDate time.Time `json:"advice_date"` + PromptVersion string `json:"prompt_version"` + Provider string `json:"provider"` + Model string `json:"model"` + InputSnapshot string `json:"input_snapshot"` + Advice string `json:"advice"` + TokensIn *int `json:"tokens_in,omitempty"` + TokensOut *int `json:"tokens_out,omitempty"` + CostCent *int `json:"cost_cent,omitempty"` + CreateTime *int64 `json:"createtime,omitempty"` + UpdateTime *int64 `json:"updatetime,omitempty"` +} + +type ListSmokeAIAdvicesResult struct { + List []SmokeAIAdviceItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SmokeAIAdviceUpsertInput struct { + UID *int + Type *string + AdviceDate *time.Time + PromptVersion *string + Provider *string + Model *string + InputSnapshot *string + Advice *string + TokensIn *int + TokensOut *int + CostCent *int +} + +func (s *Service) ListSmokeAIAdvices(ctx context.Context, query ListSmokeAIAdvicesQuery) (*ListSmokeAIAdvicesResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + query.Type = strings.TrimSpace(query.Type) + + dbQuery := s.db.WithContext(ctx). + Model(&smokemodel.SmokeAIAdvice{}). + Where("deletetime IS NULL OR deletetime = 0") + if query.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", query.UID) + } + if query.Type != "" { + dbQuery = dbQuery.Where("type = ?", query.Type) + } + if query.AdviceDate != nil { + dbQuery = dbQuery.Where("advice_date = ?", query.AdviceDate.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + rows := make([]smokemodel.SmokeAIAdvice, 0) + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(query.PageSize). + Offset((query.Page - 1) * query.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + list := make([]SmokeAIAdviceItem, 0, len(rows)) + for _, row := range rows { + list = append(list, convertSmokeAIAdvice(row)) + } + return &ListSmokeAIAdvicesResult{List: list, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (s *Service) GetSmokeAIAdvice(ctx context.Context, id uint) (*SmokeAIAdviceItem, error) { + var row smokemodel.SmokeAIAdvice + if err := s.db.WithContext(ctx). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeAIAdviceNotFound + } + return nil, err + } + item := convertSmokeAIAdvice(row) + return &item, nil +} + +func (s *Service) CreateSmokeAIAdvice(ctx context.Context, input SmokeAIAdviceUpsertInput) (*SmokeAIAdviceItem, error) { + if input.UID == nil || *input.UID <= 0 || input.Advice == nil || strings.TrimSpace(*input.Advice) == "" || input.AdviceDate == nil { + return nil, ErrInvalidInput + } + now := nowUnixPtr() + typeValue := "daily_advice" + if input.Type != nil && strings.TrimSpace(*input.Type) != "" { + typeValue = strings.TrimSpace(*input.Type) + } + promptVersion := "v1" + if input.PromptVersion != nil && strings.TrimSpace(*input.PromptVersion) != "" { + promptVersion = strings.TrimSpace(*input.PromptVersion) + } + row := smokemodel.SmokeAIAdvice{ + UID: *input.UID, + Type: typeValue, + AdviceDate: *input.AdviceDate, + PromptVersion: promptVersion, + Advice: strings.TrimSpace(*input.Advice), + CreateTime: now, + UpdateTime: now, + } + applySmokeAIAdviceInput(&row, input) + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrInvalidInput + } + return nil, err + } + return s.GetSmokeAIAdvice(ctx, row.ID) +} + +func (s *Service) UpdateSmokeAIAdvice(ctx context.Context, id uint, input SmokeAIAdviceUpsertInput) (*SmokeAIAdviceItem, error) { + var row smokemodel.SmokeAIAdvice + if err := s.db.WithContext(ctx). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeAIAdviceNotFound + } + return nil, err + } + applySmokeAIAdviceInput(&row, input) + now := time.Now().Unix() + row.UpdateTime = &now + if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + return s.GetSmokeAIAdvice(ctx, id) +} + +func (s *Service) DeleteSmokeAIAdvice(ctx context.Context, id uint) error { + now := time.Now().Unix() + result := s.db.WithContext(ctx). + Model(&smokemodel.SmokeAIAdvice{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(map[string]interface{}{"deletetime": now, "updatetime": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeAIAdviceNotFound + } + return nil +} + +func convertSmokeAIAdvice(row smokemodel.SmokeAIAdvice) SmokeAIAdviceItem { + return SmokeAIAdviceItem{ + ID: row.ID, + UID: row.UID, + Type: row.Type, + AdviceDate: row.AdviceDate, + PromptVersion: row.PromptVersion, + Provider: row.Provider, + Model: row.Model, + InputSnapshot: string(row.InputSnapshot), + Advice: row.Advice, + TokensIn: row.TokensIn, + TokensOut: row.TokensOut, + CostCent: row.CostCent, + CreateTime: row.CreateTime, + UpdateTime: row.UpdateTime, + } +} + +func applySmokeAIAdviceInput(row *smokemodel.SmokeAIAdvice, input SmokeAIAdviceUpsertInput) { + if input.UID != nil && *input.UID > 0 { + row.UID = *input.UID + } + if input.Type != nil && strings.TrimSpace(*input.Type) != "" { + row.Type = strings.TrimSpace(*input.Type) + } + if input.AdviceDate != nil { + row.AdviceDate = *input.AdviceDate + } + if input.PromptVersion != nil && strings.TrimSpace(*input.PromptVersion) != "" { + row.PromptVersion = strings.TrimSpace(*input.PromptVersion) + } + if input.Provider != nil { + row.Provider = strings.TrimSpace(*input.Provider) + } + if input.Model != nil { + row.Model = strings.TrimSpace(*input.Model) + } + if input.InputSnapshot != nil { + row.InputSnapshot = []byte(strings.TrimSpace(*input.InputSnapshot)) + } + if input.Advice != nil { + row.Advice = strings.TrimSpace(*input.Advice) + } + if input.TokensIn != nil { + row.TokensIn = input.TokensIn + } + if input.TokensOut != nil { + row.TokensOut = input.TokensOut + } + if input.CostCent != nil { + row.CostCent = input.CostCent + } +} + +// ===== AI 解锁(fa_smoke_ai_advice_unlocks) ===== + +type ListSmokeAIUnlocksQuery struct { + Page int + PageSize int + UID int + UnlockDate *time.Time +} + +type SmokeAIUnlockItem struct { + ID uint `json:"id"` + UID int `json:"uid"` + UnlockDate time.Time `json:"unlock_date"` + AdWatchedAt time.Time `json:"ad_watched_at"` + CreateTime *int64 `json:"createtime,omitempty"` + UpdateTime *int64 `json:"updatetime,omitempty"` +} + +type ListSmokeAIUnlocksResult struct { + List []SmokeAIUnlockItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SmokeAIUnlockUpsertInput struct { + UID *int + UnlockDate *time.Time + AdWatchedAt *time.Time +} + +func (s *Service) ListSmokeAIUnlocks(ctx context.Context, query ListSmokeAIUnlocksQuery) (*ListSmokeAIUnlocksResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + + dbQuery := s.db.WithContext(ctx). + Model(&smokemodel.SmokeAIAdviceUnlock{}). + Where("deletetime IS NULL OR deletetime = 0") + if query.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", query.UID) + } + if query.UnlockDate != nil { + dbQuery = dbQuery.Where("unlock_date = ?", query.UnlockDate.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + rows := make([]smokemodel.SmokeAIAdviceUnlock, 0) + if total > 0 { + if err := dbQuery.Order("id DESC").Limit(query.PageSize).Offset((query.Page - 1) * query.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + } + list := make([]SmokeAIUnlockItem, 0, len(rows)) + for _, row := range rows { + list = append(list, SmokeAIUnlockItem{ID: row.ID, UID: row.UID, UnlockDate: row.UnlockDate, AdWatchedAt: row.AdWatchedAt, CreateTime: row.CreateTime, UpdateTime: row.UpdateTime}) + } + return &ListSmokeAIUnlocksResult{List: list, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (s *Service) GetSmokeAIUnlock(ctx context.Context, id uint) (*SmokeAIUnlockItem, error) { + var row smokemodel.SmokeAIAdviceUnlock + if err := s.db.WithContext(ctx).Where("id = ?", id).Where("deletetime IS NULL OR deletetime = 0").First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeAIUnlockNotFound + } + return nil, err + } + return &SmokeAIUnlockItem{ID: row.ID, UID: row.UID, UnlockDate: row.UnlockDate, AdWatchedAt: row.AdWatchedAt, CreateTime: row.CreateTime, UpdateTime: row.UpdateTime}, nil +} + +func (s *Service) CreateSmokeAIUnlock(ctx context.Context, input SmokeAIUnlockUpsertInput) (*SmokeAIUnlockItem, error) { + if input.UID == nil || *input.UID <= 0 || input.UnlockDate == nil || input.AdWatchedAt == nil { + return nil, ErrInvalidInput + } + now := nowUnixPtr() + row := smokemodel.SmokeAIAdviceUnlock{ + UID: *input.UID, + UnlockDate: *input.UnlockDate, + AdWatchedAt: *input.AdWatchedAt, + CreateTime: now, + UpdateTime: now, + } + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrInvalidInput + } + return nil, err + } + return s.GetSmokeAIUnlock(ctx, row.ID) +} + +func (s *Service) UpdateSmokeAIUnlock(ctx context.Context, id uint, input SmokeAIUnlockUpsertInput) (*SmokeAIUnlockItem, error) { + updates := map[string]interface{}{"updatetime": time.Now().Unix()} + if input.UID != nil && *input.UID > 0 { + updates["uid"] = *input.UID + } + if input.UnlockDate != nil { + updates["unlock_date"] = *input.UnlockDate + } + if input.AdWatchedAt != nil { + updates["ad_watched_at"] = *input.AdWatchedAt + } + result := s.db.WithContext(ctx).Model(&smokemodel.SmokeAIAdviceUnlock{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(updates) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrSmokeAIUnlockNotFound + } + return s.GetSmokeAIUnlock(ctx, id) +} + +func (s *Service) DeleteSmokeAIUnlock(ctx context.Context, id uint) error { + now := time.Now().Unix() + result := s.db.WithContext(ctx).Model(&smokemodel.SmokeAIAdviceUnlock{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(map[string]interface{}{"deletetime": now, "updatetime": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeAIUnlockNotFound + } + return nil +} + +// ===== AI 下次抽烟节点(fa_smoke_ai_next_smoke) ===== + +type ListSmokeAINextsQuery struct { + Page int + PageSize int + UID int + PlanDate *time.Time +} + +type SmokeAINextItem struct { + ID uint `json:"id"` + UID int `json:"uid"` + PlanDate time.Time `json:"plan_date"` + AIAdviceID uint `json:"ai_advice_id"` + NodeType string `json:"node_type"` + NodeAt time.Time `json:"node_at"` + CreateTime *int64 `json:"createtime,omitempty"` + UpdateTime *int64 `json:"updatetime,omitempty"` +} + +type ListSmokeAINextsResult struct { + List []SmokeAINextItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SmokeAINextUpsertInput struct { + UID *int + PlanDate *time.Time + AIAdviceID *uint + NodeType *string + NodeAt *time.Time +} + +func (s *Service) ListSmokeAINexts(ctx context.Context, query ListSmokeAINextsQuery) (*ListSmokeAINextsResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + + dbQuery := s.db.WithContext(ctx). + Model(&smokemodel.SmokeAINextSmoke{}). + Where("deletetime IS NULL OR deletetime = 0") + if query.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", query.UID) + } + if query.PlanDate != nil { + dbQuery = dbQuery.Where("plan_date = ?", query.PlanDate.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + rows := make([]smokemodel.SmokeAINextSmoke, 0) + if total > 0 { + if err := dbQuery.Order("id DESC").Limit(query.PageSize).Offset((query.Page - 1) * query.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + } + list := make([]SmokeAINextItem, 0, len(rows)) + for _, row := range rows { + list = append(list, SmokeAINextItem{ID: row.ID, UID: row.UID, PlanDate: row.PlanDate, AIAdviceID: row.AIAdviceID, NodeType: row.NodeType, NodeAt: row.NodeAt, CreateTime: row.CreateTime, UpdateTime: row.UpdateTime}) + } + return &ListSmokeAINextsResult{List: list, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (s *Service) GetSmokeAINext(ctx context.Context, id uint) (*SmokeAINextItem, error) { + var row smokemodel.SmokeAINextSmoke + if err := s.db.WithContext(ctx).Where("id = ?", id).Where("deletetime IS NULL OR deletetime = 0").First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeAINextNotFound + } + return nil, err + } + return &SmokeAINextItem{ID: row.ID, UID: row.UID, PlanDate: row.PlanDate, AIAdviceID: row.AIAdviceID, NodeType: row.NodeType, NodeAt: row.NodeAt, CreateTime: row.CreateTime, UpdateTime: row.UpdateTime}, nil +} + +func (s *Service) CreateSmokeAINext(ctx context.Context, input SmokeAINextUpsertInput) (*SmokeAINextItem, error) { + if input.UID == nil || *input.UID <= 0 || input.PlanDate == nil || input.AIAdviceID == nil || *input.AIAdviceID == 0 || input.NodeType == nil || strings.TrimSpace(*input.NodeType) == "" || input.NodeAt == nil { + return nil, ErrInvalidInput + } + now := nowUnixPtr() + row := smokemodel.SmokeAINextSmoke{ + UID: *input.UID, + PlanDate: *input.PlanDate, + AIAdviceID: *input.AIAdviceID, + NodeType: strings.TrimSpace(*input.NodeType), + NodeAt: *input.NodeAt, + CreateTime: now, + UpdateTime: now, + } + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + if isDuplicateError(err) { + return nil, ErrInvalidInput + } + return nil, err + } + return s.GetSmokeAINext(ctx, row.ID) +} + +func (s *Service) UpdateSmokeAINext(ctx context.Context, id uint, input SmokeAINextUpsertInput) (*SmokeAINextItem, error) { + updates := map[string]interface{}{"updatetime": time.Now().Unix()} + if input.UID != nil && *input.UID > 0 { + updates["uid"] = *input.UID + } + if input.PlanDate != nil { + updates["plan_date"] = *input.PlanDate + } + if input.AIAdviceID != nil && *input.AIAdviceID > 0 { + updates["ai_advice_id"] = *input.AIAdviceID + } + if input.NodeType != nil { + nodeType := strings.TrimSpace(*input.NodeType) + if nodeType == "" { + return nil, ErrInvalidInput + } + updates["node_type"] = nodeType + } + if input.NodeAt != nil { + updates["node_at"] = *input.NodeAt + } + + result := s.db.WithContext(ctx).Model(&smokemodel.SmokeAINextSmoke{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(updates) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 0 { + return nil, ErrSmokeAINextNotFound + } + return s.GetSmokeAINext(ctx, id) +} + +func (s *Service) DeleteSmokeAINext(ctx context.Context, id uint) error { + now := time.Now().Unix() + result := s.db.WithContext(ctx).Model(&smokemodel.SmokeAINextSmoke{}). + Where("id = ?", id). + Where("deletetime IS NULL OR deletetime = 0"). + Updates(map[string]interface{}{"deletetime": now, "updatetime": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeAINextNotFound + } + return nil +} + +// ===== 激励语模板(fa_smoke_motivation_quote) ===== + +type ListSmokeMotivationsQuery struct { + Page int + PageSize int + Scene string + Type string + Enabled *bool +} + +type SmokeMotivationItem struct { + ID uint `json:"id"` + Scene string `json:"scene"` + Type string `json:"type"` + Message string `json:"message"` + AIPrompt string `json:"ai_prompt"` + Enabled bool `json:"enabled"` + Weight int `json:"weight"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListSmokeMotivationsResult struct { + List []SmokeMotivationItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type SmokeMotivationUpsertInput struct { + Scene *string + Type *string + Message *string + AIPrompt *string + Enabled *bool + Weight *int +} + +func (s *Service) ListSmokeMotivations(ctx context.Context, query ListSmokeMotivationsQuery) (*ListSmokeMotivationsResult, error) { + query.Page, query.PageSize = normalizePage(query.Page, query.PageSize) + query.Scene = strings.TrimSpace(query.Scene) + query.Type = strings.TrimSpace(query.Type) + + dbQuery := s.db.WithContext(ctx).Model(&smokemodel.SmokeMotivationQuote{}) + if query.Scene != "" { + dbQuery = dbQuery.Where("scene = ?", query.Scene) + } + if query.Type != "" { + dbQuery = dbQuery.Where("type = ?", query.Type) + } + if query.Enabled != nil { + dbQuery = dbQuery.Where("enabled = ?", *query.Enabled) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + rows := make([]smokemodel.SmokeMotivationQuote, 0) + if total > 0 { + if err := dbQuery.Order("id DESC").Limit(query.PageSize).Offset((query.Page - 1) * query.PageSize).Find(&rows).Error; err != nil { + return nil, err + } + } + list := make([]SmokeMotivationItem, 0, len(rows)) + for _, row := range rows { + list = append(list, SmokeMotivationItem{ID: row.ID, Scene: row.Scene, Type: row.Type, Message: row.Message, AIPrompt: row.AIPrompt, Enabled: row.Enabled, Weight: row.Weight, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}) + } + return &ListSmokeMotivationsResult{List: list, Total: total, Page: query.Page, PageSize: query.PageSize}, nil +} + +func (s *Service) GetSmokeMotivation(ctx context.Context, id uint) (*SmokeMotivationItem, error) { + var row smokemodel.SmokeMotivationQuote + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeMotivationNotFound + } + return nil, err + } + return &SmokeMotivationItem{ID: row.ID, Scene: row.Scene, Type: row.Type, Message: row.Message, AIPrompt: row.AIPrompt, Enabled: row.Enabled, Weight: row.Weight, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}, nil +} + +func (s *Service) CreateSmokeMotivation(ctx context.Context, input SmokeMotivationUpsertInput) (*SmokeMotivationItem, error) { + if input.Scene == nil || strings.TrimSpace(*input.Scene) == "" || input.Type == nil || strings.TrimSpace(*input.Type) == "" || input.Message == nil || strings.TrimSpace(*input.Message) == "" { + return nil, ErrInvalidInput + } + row := smokemodel.SmokeMotivationQuote{Scene: strings.TrimSpace(*input.Scene), Type: strings.TrimSpace(*input.Type), Message: strings.TrimSpace(*input.Message), Enabled: true, Weight: 1} + applySmokeMotivationInput(&row, input) + if err := s.db.WithContext(ctx).Create(&row).Error; err != nil { + return nil, err + } + return s.GetSmokeMotivation(ctx, row.ID) +} + +func (s *Service) UpdateSmokeMotivation(ctx context.Context, id uint, input SmokeMotivationUpsertInput) (*SmokeMotivationItem, error) { + var row smokemodel.SmokeMotivationQuote + if err := s.db.WithContext(ctx).First(&row, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeMotivationNotFound + } + return nil, err + } + applySmokeMotivationInput(&row, input) + if err := s.db.WithContext(ctx).Save(&row).Error; err != nil { + return nil, err + } + return s.GetSmokeMotivation(ctx, id) +} + +func (s *Service) DeleteSmokeMotivation(ctx context.Context, id uint) error { + result := s.db.WithContext(ctx).Delete(&smokemodel.SmokeMotivationQuote{}, id) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrSmokeMotivationNotFound + } + return nil +} + +func applySmokeMotivationInput(row *smokemodel.SmokeMotivationQuote, input SmokeMotivationUpsertInput) { + if input.Scene != nil { + row.Scene = strings.TrimSpace(*input.Scene) + } + if input.Type != nil { + row.Type = strings.TrimSpace(*input.Type) + } + if input.Message != nil { + row.Message = strings.TrimSpace(*input.Message) + } + if input.AIPrompt != nil { + row.AIPrompt = strings.TrimSpace(*input.AIPrompt) + } + if input.Enabled != nil { + row.Enabled = *input.Enabled + } + if input.Weight != nil { + row.Weight = *input.Weight + } +} + +func nowUnixPtr() *int64 { + now := time.Now().Unix() + return &now +} diff --git a/internal/admin/service/types.go b/internal/admin/service/types.go index e69026c..be16583 100644 --- a/internal/admin/service/types.go +++ b/internal/admin/service/types.go @@ -18,6 +18,12 @@ var ( ErrMiniProgramAppIDUsed = errors.New("mini program app_id already exists") ErrAdminUserNotFound = errors.New("admin user not found") ErrMembershipRedeemCodeNotFound = errors.New("membership redeem code not found") + ErrSmokeLogNotFound = errors.New("smoke log not found") + ErrSmokeProfileNotFound = errors.New("smoke profile not found") + ErrSmokeAIAdviceNotFound = errors.New("smoke ai advice not found") + ErrSmokeAIUnlockNotFound = errors.New("smoke ai unlock not found") + ErrSmokeAINextNotFound = errors.New("smoke ai next smoke not found") + ErrSmokeMotivationNotFound = errors.New("smoke motivation quote not found") ErrInvalidInput = errors.New("invalid input") ErrPasswordPolicyViolation = errors.New("password policy violation") ) diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 02e4875..3eb6a8c 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -52,6 +52,43 @@ func registerAdminRoutes( protected.GET("/watermark/video-parse-unlocks", handler.ListVideoParseUnlocks) protected.GET("/watermark/video-download-failures", handler.ListVideoDownloadFailures) + // 戒烟小程序(fa_smoke)后台管理接口。 + protected.GET("/smoke/logs", handler.ListSmokeLogs) + protected.GET("/smoke/logs/:id", handler.GetSmokeLog) + protected.POST("/smoke/logs", handler.CreateSmokeLog) + protected.PUT("/smoke/logs/:id", handler.UpdateSmokeLog) + protected.DELETE("/smoke/logs/:id", handler.DeleteSmokeLog) + + protected.GET("/smoke/profiles", handler.ListSmokeProfiles) + protected.GET("/smoke/profiles/:id", handler.GetSmokeProfile) + protected.POST("/smoke/profiles", handler.CreateSmokeProfile) + protected.PUT("/smoke/profiles/:id", handler.UpdateSmokeProfile) + protected.DELETE("/smoke/profiles/:id", handler.DeleteSmokeProfile) + + protected.GET("/smoke/ai-advices", handler.ListSmokeAIAdvices) + protected.GET("/smoke/ai-advices/:id", handler.GetSmokeAIAdvice) + protected.POST("/smoke/ai-advices", handler.CreateSmokeAIAdvice) + protected.PUT("/smoke/ai-advices/:id", handler.UpdateSmokeAIAdvice) + protected.DELETE("/smoke/ai-advices/:id", handler.DeleteSmokeAIAdvice) + + protected.GET("/smoke/ai-unlocks", handler.ListSmokeAIUnlocks) + protected.GET("/smoke/ai-unlocks/:id", handler.GetSmokeAIUnlock) + protected.POST("/smoke/ai-unlocks", handler.CreateSmokeAIUnlock) + protected.PUT("/smoke/ai-unlocks/:id", handler.UpdateSmokeAIUnlock) + protected.DELETE("/smoke/ai-unlocks/:id", handler.DeleteSmokeAIUnlock) + + protected.GET("/smoke/ai-next-smokes", handler.ListSmokeAINexts) + protected.GET("/smoke/ai-next-smokes/:id", handler.GetSmokeAINext) + protected.POST("/smoke/ai-next-smokes", handler.CreateSmokeAINext) + protected.PUT("/smoke/ai-next-smokes/:id", handler.UpdateSmokeAINext) + protected.DELETE("/smoke/ai-next-smokes/:id", handler.DeleteSmokeAINext) + + protected.GET("/smoke/motivation-quotes", handler.ListSmokeMotivations) + protected.GET("/smoke/motivation-quotes/:id", handler.GetSmokeMotivation) + protected.POST("/smoke/motivation-quotes", handler.CreateSmokeMotivation) + protected.PUT("/smoke/motivation-quotes/:id", handler.UpdateSmokeMotivation) + protected.DELETE("/smoke/motivation-quotes/:id", handler.DeleteSmokeMotivation) + protected.GET("/memberships/overview", handler.MembershipOverview) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)