diff --git a/cmd/api/main.go b/cmd/api/main.go index 895b525..963cda4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -49,6 +49,7 @@ func main() { &rmmodel.VideoParseUnlock{}, &rmmodel.VideoDownloadFailure{}, &smokemodel.SmokeLog{}, + &smokemodel.SmokeUserProfile{}, &smokemodel.SmokeAIAdvice{}, &smokemodel.SmokeAIAdviceUnlock{}, ); err != nil { @@ -71,7 +72,8 @@ func main() { smokeLogService := smokeservice.NewSmokeLogService(database.DB) smokeAIAdviceService := smokeservice.NewSmokeAIAdviceService(database.DB, config.AppConfig.AI) - smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService) + smokeProfileService := smokeservice.NewSmokeProfileService(database.DB) + smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService) diff --git a/docs/smoke/API.md b/docs/smoke/API.md index 7e37e66..ad709a1 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -237,3 +237,87 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ 说明: - 该接口用于记录“已完成观看广告”,落库到 `fa_smoke_ai_advice_unlocks`(`uid + unlock_date` 唯一)。 - `ad_watched_at` 可由后端取当前时间;如需审计/对账可保留前端上报并做校验。 + +## 10) 获取用户基础信息(首次进入:判断是否需要补全) + +`GET /api/v1/smoke/profile` + +说明: +- 首次进入小程序建议先调用该接口:若 `exists=false` 或 `is_completed=false`,前端进入“信息补全”流程。 +- `baseline_interval_minutes` 用于建立初始基准:在用户清醒时段内的“平均间隔(分钟)”。计算:`awake_minutes / baseline_cigs_per_day`。 +- 若未提供作息时间(起床/入睡),后端会用默认清醒时长 `16*60=960` 分钟参与计算。 + +成功响应(示例): + +```json +{ + "code": 200, + "message": "success", + "data": { + "exists": true, + "profile": { + "id": 1, + "created_at": "2026-01-05T10:00:00+08:00", + "updated_at": "2026-01-05T10:00:00+08:00", + "baseline_cigs_per_day": 20, + "smoking_years": 8, + "pack_price_cent": 2500, + "smoke_motivations": ["压力大", "社交"], + "quit_motivations": ["身体健康", "省钱"], + "wake_up_time": "07:30", + "sleep_time": "23:30", + "onboarding_completed_at": "2026-01-05T10:00:00+08:00" + }, + "is_completed": true, + "awake_minutes": 960, + "baseline_interval_minutes": 48 + } +} +``` + +当 `exists=false`(尚未补全)时,响应示例: + +```json +{ + "code": 200, + "message": "success", + "data": { + "exists": false, + "is_completed": false, + "awake_minutes": 960, + "baseline_interval_minutes": 0 + } +} +``` + +字段用途(补全页面可参考): +- `baseline_cigs_per_day`(基础烟量/日均抽烟支数):建立初始基准,计算初始建议间隔时长。 +- `smoking_years`(烟龄/年)+ `pack_price_cent`(单包价格/分):用于看板计算“已省金额”和“恢复时长”等指标(公式可在看板端实现)。 +- `smoke_motivations`(抽烟动机):如 `压力大/无聊/社交/提神`,用于 AI 在分析 remark 时更有针对性。 +- `quit_motivations`(戒烟动力):如 `身体健康/家人孩子/省钱`,当用户产生动摇时 AI 可用这些信息做“情感阻断/自我提醒”。 +- `wake_up_time` + `sleep_time`(作息时间):用于自动规避睡眠时间,防止在用户睡觉时提醒其“坚持”。 + +## 11) 补全/更新用户基础信息(Upsert) + +`PUT /api/v1/smoke/profile` + +说明: +- 字段按需传;首次进入建议一次性补全。 +- 作息时间格式:`HH:MM`(24 小时制),例如 `07:30`、`23:10`。 +- `pack_price_cent` 为“分”;若前端用“元”,请乘以 100。 + +请求体(示例): + +```json +{ + "baseline_cigs_per_day": 20, + "smoking_years": 8, + "pack_price_cent": 2500, + "smoke_motivations": ["压力大", "社交"], + "quit_motivations": ["身体健康", "省钱"], + "wake_up_time": "07:30", + "sleep_time": "23:30" +} +``` + +成功响应:同 `GET /api/v1/smoke/profile`(返回最新 `profile` + `is_completed` + `baseline_interval_minutes`)。 diff --git a/docs/smoke/README.md b/docs/smoke/README.md index a5058bc..14bef5e 100644 --- a/docs/smoke/README.md +++ b/docs/smoke/README.md @@ -15,6 +15,16 @@ - 该表使用旧系统字段:`createtime/updatetime/deletetime`(秒级时间戳),并非 GORM 默认的 `created_at/updated_at/deleted_at`。 - 接口层通过 Token 识别用户,`uid` 由后端从登录用户推导,不允许前端传入。 +### 用户基础信息(首次进入补全) + +用于建立“初始基准/个性化策略”的用户信息表: +- `fa_smoke_user_profile`(DDL 见:`docs/sql/smoke.sql`) + +典型字段: +- 日均抽烟支数、烟龄、单包价格 +- 抽烟动机、戒烟动力 +- 起床/入睡时间(用于规避睡眠时间) + ### 真实抽烟时间(推荐使用 `smoke_at`) 为支持“按时间节点分析”(例如:昨天哪些时段更容易想抽),建议在 `fa_smoke_log` 中新增: diff --git a/docs/sql/smoke.sql b/docs/sql/smoke.sql index 81a056c..143a5e0 100644 --- a/docs/sql/smoke.sql +++ b/docs/sql/smoke.sql @@ -54,3 +54,26 @@ CREATE TABLE IF NOT EXISTS `fa_smoke_ai_advice_unlocks` ( UNIQUE KEY `uniq_smoke_ai_unlock` (`uid`,`unlock_date`), KEY `idx_smoke_ai_unlock_uid_date` (`uid`,`unlock_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI戒烟建议-广告解锁'; + +-- 用户基础信息(首次进入补全,用于基准/AI/看板公式等) +-- 说明: +-- - 使用 GORM 默认 created_at/updated_at/deleted_at(datetime(3)) +-- - smoke_motivations/quit_motivations 建议存 JSON 数组(例如 ["压力大","社交"]) +CREATE TABLE IF NOT EXISTS `fa_smoke_user_profile` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) NULL DEFAULT NULL, + `updated_at` datetime(3) NULL DEFAULT NULL, + `deleted_at` datetime(3) NULL DEFAULT NULL, + `uid` int NOT NULL COMMENT '用户ID', + `baseline_cigs_per_day` int NOT NULL DEFAULT 0 COMMENT '基础烟量(日均抽烟支数)', + `smoking_years` decimal(6,2) NOT NULL DEFAULT 0.00 COMMENT '烟龄(年)', + `pack_price_cent` int NOT NULL DEFAULT 0 COMMENT '单包价格(分)', + `smoke_motivations` json DEFAULT NULL COMMENT '抽烟动机(JSON数组)', + `quit_motivations` json DEFAULT NULL COMMENT '戒烟动力(JSON数组)', + `wake_up_time` varchar(5) NOT NULL DEFAULT '' COMMENT '起床时间(HH:MM)', + `sleep_time` varchar(5) NOT NULL DEFAULT '' COMMENT '入睡时间(HH:MM)', + `onboarding_completed_at` datetime(3) DEFAULT NULL COMMENT '首次补全完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_smoke_profile_uid` (`uid`), + KEY `idx_smoke_profile_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='戒烟-用户基础信息'; diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 1f16c0e..94d23e4 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -10,6 +10,10 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. // 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开) smoke := protected.Group("/smoke") { + // 首次进入/基础信息(用于基准、AI 个性化、作息规避等) + smoke.GET("/profile", smokeHandler.GetProfile) + smoke.PUT("/profile", smokeHandler.UpsertProfile) + smoke.GET("/dashboard", smokeHandler.Dashboard) smoke.POST("/logs", smokeHandler.Create) smoke.GET("/logs", smokeHandler.List) diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index b6eb0ba..0fea49c 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -16,12 +16,14 @@ import ( type SmokeHandler struct { smokeLogService *smokeservice.SmokeLogService smokeAIAdviceService *smokeservice.SmokeAIAdviceService + smokeProfileService *smokeservice.SmokeProfileService } -func NewSmokeHandler(smokeLogService *smokeservice.SmokeLogService, smokeAIAdviceService *smokeservice.SmokeAIAdviceService) *SmokeHandler { +func NewSmokeHandler(smokeLogService *smokeservice.SmokeLogService, smokeAIAdviceService *smokeservice.SmokeAIAdviceService, smokeProfileService *smokeservice.SmokeProfileService) *SmokeHandler { return &SmokeHandler{ smokeLogService: smokeLogService, smokeAIAdviceService: smokeAIAdviceService, + smokeProfileService: smokeProfileService, } } diff --git a/internal/smoke/handler/smoke_profile_handler.go b/internal/smoke/handler/smoke_profile_handler.go new file mode 100644 index 0000000..559a4e9 --- /dev/null +++ b/internal/smoke/handler/smoke_profile_handler.go @@ -0,0 +1,99 @@ +package handler + +import ( + "errors" + "io" + "net/http" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" + smokeservice "wx_service/internal/smoke/service" +) + +type upsertSmokeProfileRequest struct { + 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"` +} + +func (h *SmokeHandler) GetProfile(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + view, err := h.smokeProfileService.GetView(c.Request.Context(), 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 + } + + c.JSON(http.StatusOK, model.Success(view)) +} + +func (h *SmokeHandler) UpsertProfile(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + var req upsertSmokeProfileRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + if req.BaselineCigsPerDay != nil { + if *req.BaselineCigsPerDay < 0 || *req.BaselineCigsPerDay > 300 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "baseline_cigs_per_day 应在 0~300")) + return + } + } + if req.SmokingYears != nil { + if *req.SmokingYears < 0 || *req.SmokingYears > 80 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "smoking_years 应在 0~80")) + return + } + } + if req.PackPriceCent != nil { + if *req.PackPriceCent < 0 || *req.PackPriceCent > 1000000 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "pack_price_cent 应在 0~1000000")) + return + } + } + + view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{ + BaselineCigsPerDay: req.BaselineCigsPerDay, + SmokingYears: req.SmokingYears, + PackPriceCent: req.PackPriceCent, + SmokeMotivations: req.SmokeMotivations, + QuitMotivations: req.QuitMotivations, + WakeUpTime: req.WakeUpTime, + SleepTime: req.SleepTime, + }) + 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 + } + + c.JSON(http.StatusOK, model.Success(view)) +} + diff --git a/internal/smoke/model/smoke_profile.go b/internal/smoke/model/smoke_profile.go new file mode 100644 index 0000000..d37b83f --- /dev/null +++ b/internal/smoke/model/smoke_profile.go @@ -0,0 +1,83 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "time" + + "gorm.io/gorm" +) + +// StringSlice 是一个以 JSON 数组形式存储到 MySQL 的 []string。 +type StringSlice []string + +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return "[]", nil + } + b, err := json.Marshal([]string(s)) + if err != nil { + return nil, err + } + return string(b), nil +} + +func (s *StringSlice) Scan(value any) error { + if s == nil { + return errors.New("StringSlice: Scan on nil receiver") + } + switch v := value.(type) { + case nil: + *s = nil + return nil + case []byte: + if len(v) == 0 { + *s = nil + return nil + } + return json.Unmarshal(v, s) + case string: + if v == "" { + *s = nil + return nil + } + return json.Unmarshal([]byte(v), s) + default: + return fmt.Errorf("StringSlice: unsupported Scan type %T", value) + } +} + +// SmokeUserProfile 存储用户“首次进入小程序”的戒烟基础信息,用于: +// - 建立初始基准(例如:基于日均支数计算初始建议间隔) +// - AI 个性化(动机/动力/作息) +// - 看板指标计算(如已省金额、恢复时长等) +type SmokeUserProfile struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + UID int `gorm:"uniqueIndex;comment:用户ID" json:"-"` + + BaselineCigsPerDay int `gorm:"column:baseline_cigs_per_day;comment:基础烟量(日均抽烟支数)" json:"baseline_cigs_per_day"` + SmokingYears float64 `gorm:"column:smoking_years;type:decimal(6,2);comment:烟龄(年)" json:"smoking_years"` + PackPriceCent int `gorm:"column:pack_price_cent;comment:单包价格(分)" json:"pack_price_cent"` + + SmokeMotivations StringSlice `gorm:"column:smoke_motivations;type:json;comment:抽烟动机(JSON数组)" json:"smoke_motivations"` + QuitMotivations StringSlice `gorm:"column:quit_motivations;type:json;comment:戒烟动力(JSON数组)" json:"quit_motivations"` + + 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"` + + OnboardingCompletedAt *time.Time `gorm:"column:onboarding_completed_at;comment:首次补全完成时间" json:"onboarding_completed_at,omitempty"` +} + +func (SmokeUserProfile) TableName() string { + return "fa_smoke_user_profile" +} + +func (SmokeUserProfile) TableComment() string { + return "戒烟-用户基础信息" +} diff --git a/internal/smoke/service/smoke_ai_advice_service.go b/internal/smoke/service/smoke_ai_advice_service.go index ebc83f1..eec65c4 100644 --- a/internal/smoke/service/smoke_ai_advice_service.go +++ b/internal/smoke/service/smoke_ai_advice_service.go @@ -26,7 +26,7 @@ var ( ) const ( - DefaultAdvicePromptVersion = "v1" + DefaultAdvicePromptVersion = "v2" defaultTemperature = 0.7 ) @@ -61,6 +61,20 @@ type adviceSnapshot struct { Date string `json:"date"` TotalNum int `json:"total_num"` Nodes []adviceSnapshotNode `json:"nodes"` + Profile *adviceUserProfile `json:"profile,omitempty"` +} + +type adviceUserProfile struct { + BaselineCigsPerDay int `json:"baseline_cigs_per_day,omitempty"` + SmokingYears float64 `json:"smoking_years,omitempty"` + PackPriceCent int `json:"pack_price_cent,omitempty"` + SmokeMotivations []string `json:"smoke_motivations,omitempty"` + QuitMotivations []string `json:"quit_motivations,omitempty"` + WakeUpTime string `json:"wake_up_time,omitempty"` + SleepTime string `json:"sleep_time,omitempty"` + AwakeMinutes int `json:"awake_minutes,omitempty"` + BaselineIntervalMinutes int `json:"baseline_interval_minutes,omitempty"` + OnboardingCompletedAtISO string `json:"onboarding_completed_at,omitempty"` } func (s *SmokeAIAdviceService) GetOrGenerate(ctx context.Context, user *usermodel.User, adviceDate time.Time, promptVersion string) (*smokemodel.SmokeAIAdvice, error) { @@ -217,6 +231,8 @@ func (s *SmokeAIAdviceService) buildSnapshot(ctx context.Context, uid int, advic return adviceSnapshot{}, nil, ErrNoSmokeLogs } + profile := s.loadAdviceProfile(ctx, uid) + type timedLog struct { log smokemodel.SmokeLog eventAt time.Time @@ -273,6 +289,7 @@ func (s *SmokeAIAdviceService) buildSnapshot(ctx context.Context, uid int, advic Date: dateOnly(adviceDate).Format("2006-01-02"), TotalNum: total, Nodes: nodes, + Profile: profile, } b, err := json.Marshal(snap) @@ -282,6 +299,42 @@ func (s *SmokeAIAdviceService) buildSnapshot(ctx context.Context, uid int, advic return snap, b, nil } +func (s *SmokeAIAdviceService) loadAdviceProfile(ctx context.Context, uid int) *adviceUserProfile { + var profile smokemodel.SmokeUserProfile + err := s.db.WithContext(ctx). + Where("uid = ? AND deleted_at IS NULL", uid). + First(&profile).Error + if err != nil { + return nil + } + + awake := defaultAwakeMinutes + wake := strings.TrimSpace(profile.WakeUpTime) + sleep := strings.TrimSpace(profile.SleepTime) + if v, err := awakeMinutesWithFallback(wake, sleep); err == nil { + awake = v + } else { + wake = "" + sleep = "" + } + + out := adviceUserProfile{ + BaselineCigsPerDay: profile.BaselineCigsPerDay, + SmokingYears: profile.SmokingYears, + PackPriceCent: profile.PackPriceCent, + SmokeMotivations: []string(profile.SmokeMotivations), + QuitMotivations: []string(profile.QuitMotivations), + WakeUpTime: wake, + SleepTime: sleep, + AwakeMinutes: awake, + BaselineIntervalMinutes: baselineIntervalMinutes(awake, profile.BaselineCigsPerDay), + } + if profile.OnboardingCompletedAt != nil { + out.OnboardingCompletedAtISO = profile.OnboardingCompletedAt.In(time.Local).Format(time.RFC3339) + } + return &out +} + func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) (string, string, *int, *int, error) { if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" { return "", "", nil, nil, ErrAIServiceDisabled @@ -293,8 +346,12 @@ func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) 1) 用中文输出; 2) 先给出对昨天模式的简短分析(1-3条); 3) 给出今天的具体行动方案(至少5条,包含替代行为、触发场景应对、时间节点策略); -4) 给出一个“如果忍不住想抽”的 60 秒应对流程; -5) 语气友好、不指责;不提供医疗诊断。 +4) 如果 profile 中提供了「作息时间」,建议的执行时间点要避开用户睡眠区间; +5) 如果 profile 中提供了「抽烟动机/戒烟动力」,你需要在建议中更有针对性地引用它们: + - 动机:用于解释触发场景与 remark 的关联,给出替代行为; + - 动力:用于“情感阻断/动摇时的自我提醒”(给 2-3 条可复述的话术); +6) 给出一个“如果忍不住想抽”的 60 秒应对流程; +7) 语气友好、不指责;不提供医疗诊断。 `) userPrompt := fmt.Sprintf("用户昨日数据(JSON):\n%s", mustJSON(snap)) diff --git a/internal/smoke/service/smoke_profile_service.go b/internal/smoke/service/smoke_profile_service.go new file mode 100644 index 0000000..e5627a8 --- /dev/null +++ b/internal/smoke/service/smoke_profile_service.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + smokemodel "wx_service/internal/smoke/model" +) + +var ( + ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM") +) + +type SmokeProfileService struct { + db *gorm.DB +} + +func NewSmokeProfileService(db *gorm.DB) *SmokeProfileService { + return &SmokeProfileService{db: db} +} + +type SmokeProfileView struct { + Exists bool `json:"exists"` + Profile *smokemodel.SmokeUserProfile `json:"profile,omitempty"` + + IsCompleted bool `json:"is_completed"` + AwakeMinutes int `json:"awake_minutes"` + BaselineIntervalMinute int `json:"baseline_interval_minutes"` +} + +func (s *SmokeProfileService) GetView(ctx context.Context, uid int) (SmokeProfileView, error) { + profile, err := s.Get(ctx, uid) + if err != nil { + return SmokeProfileView{}, err + } + if profile == nil { + return SmokeProfileView{ + Exists: false, + Profile: nil, + IsCompleted: false, + AwakeMinutes: defaultAwakeMinutes, + BaselineIntervalMinute: 0, + }, nil + } + + awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime) + if err != nil { + return SmokeProfileView{}, err + } + + isCompleted := isSmokeProfileCompleted(*profile) + interval := baselineIntervalMinutes(awakeMinutes, profile.BaselineCigsPerDay) + + return SmokeProfileView{ + Exists: true, + Profile: profile, + IsCompleted: isCompleted, + AwakeMinutes: awakeMinutes, + BaselineIntervalMinute: interval, + }, nil +} + +func (s *SmokeProfileService) Get(ctx context.Context, uid int) (*smokemodel.SmokeUserProfile, error) { + var profile smokemodel.SmokeUserProfile + err := s.db.WithContext(ctx). + Where("uid = ? AND deleted_at IS NULL", uid). + First(&profile).Error + if err == nil { + return &profile, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, fmt.Errorf("load smoke profile: %w", err) +} + +type UpsertSmokeProfileRequest struct { + BaselineCigsPerDay *int + SmokingYears *float64 + PackPriceCent *int + + SmokeMotivations *[]string + QuitMotivations *[]string + + WakeUpTime *string + SleepTime *string +} + +func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) { + var profile smokemodel.SmokeUserProfile + tx := s.db.WithContext(ctx) + err := tx.Where("uid = ? AND deleted_at IS NULL", uid).First(&profile).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return SmokeProfileView{}, fmt.Errorf("load smoke profile: %w", err) + } + isNew := errors.Is(err, gorm.ErrRecordNotFound) + if isNew { + profile = smokemodel.SmokeUserProfile{UID: uid} + } + + applyInt := func(dst *int, v *int) { + if v != nil { + *dst = *v + } + } + applyFloat := func(dst *float64, v *float64) { + if v != nil { + *dst = *v + } + } + applyTimeStr := func(dst *string, v *string) error { + if v == nil { + return nil + } + value := strings.TrimSpace(*v) + if value == "" { + *dst = "" + return nil + } + if _, err := parseHHMMToMinutes(value); err != nil { + return ErrSmokeProfileInvalidTime + } + *dst = value + return nil + } + + applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay) + applyFloat(&profile.SmokingYears, req.SmokingYears) + applyInt(&profile.PackPriceCent, req.PackPriceCent) + + if req.SmokeMotivations != nil { + profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations) + } + if req.QuitMotivations != nil { + profile.QuitMotivations = smokemodel.StringSlice(*req.QuitMotivations) + } + + if err := applyTimeStr(&profile.WakeUpTime, req.WakeUpTime); err != nil { + return SmokeProfileView{}, err + } + if err := applyTimeStr(&profile.SleepTime, req.SleepTime); err != nil { + return SmokeProfileView{}, err + } + + now := time.Now() + if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) { + profile.OnboardingCompletedAt = &now + } + + if isNew { + if err := tx.Create(&profile).Error; err != nil { + return SmokeProfileView{}, fmt.Errorf("create smoke profile: %w", err) + } + } else { + if err := tx.Save(&profile).Error; err != nil { + return SmokeProfileView{}, fmt.Errorf("save smoke profile: %w", err) + } + } + + return s.GetView(ctx, uid) +} + +func isSmokeProfileCompleted(p smokemodel.SmokeUserProfile) bool { + return p.BaselineCigsPerDay > 0 && + p.PackPriceCent > 0 && + len(p.SmokeMotivations) > 0 && + len(p.QuitMotivations) > 0 && + strings.TrimSpace(p.WakeUpTime) != "" && + strings.TrimSpace(p.SleepTime) != "" +} + +const defaultAwakeMinutes = 16 * 60 + +func awakeMinutesWithFallback(wakeUp, sleep string) (int, error) { + wakeUp = strings.TrimSpace(wakeUp) + sleep = strings.TrimSpace(sleep) + if wakeUp == "" || sleep == "" { + return defaultAwakeMinutes, nil + } + wakeMin, err := parseHHMMToMinutes(wakeUp) + if err != nil { + return 0, ErrSmokeProfileInvalidTime + } + sleepMin, err := parseHHMMToMinutes(sleep) + if err != nil { + return 0, ErrSmokeProfileInvalidTime + } + if sleepMin == wakeMin { + return 24 * 60, nil + } + if sleepMin > wakeMin { + return sleepMin - wakeMin, nil + } + return (24 * 60 - wakeMin) + sleepMin, nil +} + +func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int { + if awakeMinutes <= 0 || baselineCigsPerDay <= 0 { + return 0 + } + interval := awakeMinutes / baselineCigsPerDay + if interval <= 0 { + return 1 + } + return interval +} + +func parseHHMMToMinutes(s string) (int, error) { + s = strings.TrimSpace(s) + if len(s) != 5 || s[2] != ':' { + return 0, ErrSmokeProfileInvalidTime + } + h1, h2 := s[0], s[1] + m1, m2 := s[3], s[4] + if h1 < '0' || h1 > '9' || h2 < '0' || h2 > '9' || m1 < '0' || m1 > '9' || m2 < '0' || m2 > '9' { + return 0, ErrSmokeProfileInvalidTime + } + hour := int(h1-'0')*10 + int(h2-'0') + min := int(m1-'0')*10 + int(m2-'0') + if hour < 0 || hour > 23 || min < 0 || min > 59 { + return 0, ErrSmokeProfileInvalidTime + } + return hour*60 + min, nil +} diff --git a/internal/smoke/service/smoke_profile_service_test.go b/internal/smoke/service/smoke_profile_service_test.go new file mode 100644 index 0000000..b7a4ae5 --- /dev/null +++ b/internal/smoke/service/smoke_profile_service_test.go @@ -0,0 +1,105 @@ +package service + +import ( + "testing" + + smokemodel "wx_service/internal/smoke/model" +) + +func TestParseHHMMToMinutes(t *testing.T) { + t.Parallel() + + cases := []struct { + in string + want int + wantErr bool + }{ + {"00:00", 0, false}, + {"07:30", 450, false}, + {"23:59", 23*60 + 59, false}, + {"7:30", 0, true}, + {"24:00", 0, true}, + {"12:60", 0, true}, + {"ab:cd", 0, true}, + {"", 0, true}, + } + + for _, c := range cases { + got, err := parseHHMMToMinutes(c.in) + if c.wantErr { + if err == nil { + t.Fatalf("parseHHMMToMinutes(%q): expected error", c.in) + } + continue + } + if err != nil { + t.Fatalf("parseHHMMToMinutes(%q): unexpected error: %v", c.in, err) + } + if got != c.want { + t.Fatalf("parseHHMMToMinutes(%q): got %d, want %d", c.in, got, c.want) + } + } +} + +func TestAwakeMinutesWithFallback(t *testing.T) { + t.Parallel() + + got, err := awakeMinutesWithFallback("07:00", "23:00") + if err != nil { + t.Fatalf("awakeMinutesWithFallback: %v", err) + } + if got != 16*60 { + t.Fatalf("awakeMinutesWithFallback: got %d, want %d", got, 16*60) + } + + got, err = awakeMinutesWithFallback("08:00", "01:00") + if err != nil { + t.Fatalf("awakeMinutesWithFallback (cross midnight): %v", err) + } + if got != 17*60 { + t.Fatalf("awakeMinutesWithFallback (cross midnight): got %d, want %d", got, 17*60) + } + + got, err = awakeMinutesWithFallback("", "") + if err != nil { + t.Fatalf("awakeMinutesWithFallback (empty): %v", err) + } + if got != defaultAwakeMinutes { + t.Fatalf("awakeMinutesWithFallback (empty): got %d, want %d", got, defaultAwakeMinutes) + } +} + +func TestBaselineIntervalMinutes(t *testing.T) { + t.Parallel() + + if got := baselineIntervalMinutes(960, 20); got != 48 { + t.Fatalf("baselineIntervalMinutes: got %d, want %d", got, 48) + } + if got := baselineIntervalMinutes(10, 100); got != 1 { + t.Fatalf("baselineIntervalMinutes: got %d, want %d", got, 1) + } + if got := baselineIntervalMinutes(0, 20); got != 0 { + t.Fatalf("baselineIntervalMinutes: got %d, want %d", got, 0) + } +} + +func TestIsSmokeProfileCompleted(t *testing.T) { + t.Parallel() + + p := smokemodel.SmokeUserProfile{ + BaselineCigsPerDay: 20, + PackPriceCent: 2500, + SmokeMotivations: smokemodel.StringSlice{"压力大"}, + QuitMotivations: smokemodel.StringSlice{"身体健康"}, + WakeUpTime: "07:30", + SleepTime: "23:30", + } + if !isSmokeProfileCompleted(p) { + t.Fatalf("isSmokeProfileCompleted: expected true") + } + p.SmokeMotivations = nil + if isSmokeProfileCompleted(p) { + t.Fatalf("isSmokeProfileCompleted: expected false when motivations missing") + } +} +