Add user profile management for smoking data
- Introduced new API endpoints `GET /api/v1/smoke/profile` and `PUT /api/v1/smoke/profile` for retrieving and updating user smoking profiles. - Added a new database table `fa_smoke_user_profile` to store user-specific smoking data, including daily smoking habits and motivations. - Updated the smoke handler and service to integrate user profile data into AI advice generation. - Enhanced documentation to reflect the new user profile features and their usage.
This commit is contained in:
+3
-1
@@ -49,6 +49,7 @@ func main() {
|
|||||||
&rmmodel.VideoParseUnlock{},
|
&rmmodel.VideoParseUnlock{},
|
||||||
&rmmodel.VideoDownloadFailure{},
|
&rmmodel.VideoDownloadFailure{},
|
||||||
&smokemodel.SmokeLog{},
|
&smokemodel.SmokeLog{},
|
||||||
|
&smokemodel.SmokeUserProfile{},
|
||||||
&smokemodel.SmokeAIAdvice{},
|
&smokemodel.SmokeAIAdvice{},
|
||||||
&smokemodel.SmokeAIAdviceUnlock{},
|
&smokemodel.SmokeAIAdviceUnlock{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -71,7 +72,8 @@ func main() {
|
|||||||
|
|
||||||
smokeLogService := smokeservice.NewSmokeLogService(database.DB)
|
smokeLogService := smokeservice.NewSmokeLogService(database.DB)
|
||||||
smokeAIAdviceService := smokeservice.NewSmokeAIAdviceService(database.DB, config.AppConfig.AI)
|
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)
|
redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token)
|
||||||
redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService)
|
redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService)
|
||||||
|
|||||||
@@ -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` 唯一)。
|
- 该接口用于记录“已完成观看广告”,落库到 `fa_smoke_ai_advice_unlocks`(`uid + unlock_date` 唯一)。
|
||||||
- `ad_watched_at` 可由后端取当前时间;如需审计/对账可保留前端上报并做校验。
|
- `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`)。
|
||||||
|
|||||||
@@ -15,6 +15,16 @@
|
|||||||
- 该表使用旧系统字段:`createtime/updatetime/deletetime`(秒级时间戳),并非 GORM 默认的 `created_at/updated_at/deleted_at`。
|
- 该表使用旧系统字段:`createtime/updatetime/deletetime`(秒级时间戳),并非 GORM 默认的 `created_at/updated_at/deleted_at`。
|
||||||
- 接口层通过 Token 识别用户,`uid` 由后端从登录用户推导,不允许前端传入。
|
- 接口层通过 Token 识别用户,`uid` 由后端从登录用户推导,不允许前端传入。
|
||||||
|
|
||||||
|
### 用户基础信息(首次进入补全)
|
||||||
|
|
||||||
|
用于建立“初始基准/个性化策略”的用户信息表:
|
||||||
|
- `fa_smoke_user_profile`(DDL 见:`docs/sql/smoke.sql`)
|
||||||
|
|
||||||
|
典型字段:
|
||||||
|
- 日均抽烟支数、烟龄、单包价格
|
||||||
|
- 抽烟动机、戒烟动力
|
||||||
|
- 起床/入睡时间(用于规避睡眠时间)
|
||||||
|
|
||||||
### 真实抽烟时间(推荐使用 `smoke_at`)
|
### 真实抽烟时间(推荐使用 `smoke_at`)
|
||||||
|
|
||||||
为支持“按时间节点分析”(例如:昨天哪些时段更容易想抽),建议在 `fa_smoke_log` 中新增:
|
为支持“按时间节点分析”(例如:昨天哪些时段更容易想抽),建议在 `fa_smoke_log` 中新增:
|
||||||
|
|||||||
@@ -54,3 +54,26 @@ CREATE TABLE IF NOT EXISTS `fa_smoke_ai_advice_unlocks` (
|
|||||||
UNIQUE KEY `uniq_smoke_ai_unlock` (`uid`,`unlock_date`),
|
UNIQUE KEY `uniq_smoke_ai_unlock` (`uid`,`unlock_date`),
|
||||||
KEY `idx_smoke_ai_unlock_uid_date` (`uid`,`unlock_date`)
|
KEY `idx_smoke_ai_unlock_uid_date` (`uid`,`unlock_date`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI戒烟建议-广告解锁';
|
) 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='戒烟-用户基础信息';
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
|
|||||||
// 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开)
|
// 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开)
|
||||||
smoke := protected.Group("/smoke")
|
smoke := protected.Group("/smoke")
|
||||||
{
|
{
|
||||||
|
// 首次进入/基础信息(用于基准、AI 个性化、作息规避等)
|
||||||
|
smoke.GET("/profile", smokeHandler.GetProfile)
|
||||||
|
smoke.PUT("/profile", smokeHandler.UpsertProfile)
|
||||||
|
|
||||||
smoke.GET("/dashboard", smokeHandler.Dashboard)
|
smoke.GET("/dashboard", smokeHandler.Dashboard)
|
||||||
smoke.POST("/logs", smokeHandler.Create)
|
smoke.POST("/logs", smokeHandler.Create)
|
||||||
smoke.GET("/logs", smokeHandler.List)
|
smoke.GET("/logs", smokeHandler.List)
|
||||||
|
|||||||
@@ -16,12 +16,14 @@ import (
|
|||||||
type SmokeHandler struct {
|
type SmokeHandler struct {
|
||||||
smokeLogService *smokeservice.SmokeLogService
|
smokeLogService *smokeservice.SmokeLogService
|
||||||
smokeAIAdviceService *smokeservice.SmokeAIAdviceService
|
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{
|
return &SmokeHandler{
|
||||||
smokeLogService: smokeLogService,
|
smokeLogService: smokeLogService,
|
||||||
smokeAIAdviceService: smokeAIAdviceService,
|
smokeAIAdviceService: smokeAIAdviceService,
|
||||||
|
smokeProfileService: smokeProfileService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 "戒烟-用户基础信息"
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DefaultAdvicePromptVersion = "v1"
|
DefaultAdvicePromptVersion = "v2"
|
||||||
defaultTemperature = 0.7
|
defaultTemperature = 0.7
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,6 +61,20 @@ type adviceSnapshot struct {
|
|||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
TotalNum int `json:"total_num"`
|
TotalNum int `json:"total_num"`
|
||||||
Nodes []adviceSnapshotNode `json:"nodes"`
|
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) {
|
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
|
return adviceSnapshot{}, nil, ErrNoSmokeLogs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profile := s.loadAdviceProfile(ctx, uid)
|
||||||
|
|
||||||
type timedLog struct {
|
type timedLog struct {
|
||||||
log smokemodel.SmokeLog
|
log smokemodel.SmokeLog
|
||||||
eventAt time.Time
|
eventAt time.Time
|
||||||
@@ -273,6 +289,7 @@ func (s *SmokeAIAdviceService) buildSnapshot(ctx context.Context, uid int, advic
|
|||||||
Date: dateOnly(adviceDate).Format("2006-01-02"),
|
Date: dateOnly(adviceDate).Format("2006-01-02"),
|
||||||
TotalNum: total,
|
TotalNum: total,
|
||||||
Nodes: nodes,
|
Nodes: nodes,
|
||||||
|
Profile: profile,
|
||||||
}
|
}
|
||||||
|
|
||||||
b, err := json.Marshal(snap)
|
b, err := json.Marshal(snap)
|
||||||
@@ -282,6 +299,42 @@ func (s *SmokeAIAdviceService) buildSnapshot(ctx context.Context, uid int, advic
|
|||||||
return snap, b, nil
|
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) {
|
func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot) (string, string, *int, *int, error) {
|
||||||
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" {
|
||||||
return "", "", nil, nil, ErrAIServiceDisabled
|
return "", "", nil, nil, ErrAIServiceDisabled
|
||||||
@@ -293,8 +346,12 @@ func (s *SmokeAIAdviceService) callAI(ctx context.Context, snap adviceSnapshot)
|
|||||||
1) 用中文输出;
|
1) 用中文输出;
|
||||||
2) 先给出对昨天模式的简短分析(1-3条);
|
2) 先给出对昨天模式的简短分析(1-3条);
|
||||||
3) 给出今天的具体行动方案(至少5条,包含替代行为、触发场景应对、时间节点策略);
|
3) 给出今天的具体行动方案(至少5条,包含替代行为、触发场景应对、时间节点策略);
|
||||||
4) 给出一个“如果忍不住想抽”的 60 秒应对流程;
|
4) 如果 profile 中提供了「作息时间」,建议的执行时间点要避开用户睡眠区间;
|
||||||
5) 语气友好、不指责;不提供医疗诊断。
|
5) 如果 profile 中提供了「抽烟动机/戒烟动力」,你需要在建议中更有针对性地引用它们:
|
||||||
|
- 动机:用于解释触发场景与 remark 的关联,给出替代行为;
|
||||||
|
- 动力:用于“情感阻断/动摇时的自我提醒”(给 2-3 条可复述的话术);
|
||||||
|
6) 给出一个“如果忍不住想抽”的 60 秒应对流程;
|
||||||
|
7) 语气友好、不指责;不提供医疗诊断。
|
||||||
`)
|
`)
|
||||||
|
|
||||||
userPrompt := fmt.Sprintf("用户昨日数据(JSON):\n%s", mustJSON(snap))
|
userPrompt := fmt.Sprintf("用户昨日数据(JSON):\n%s", mustJSON(snap))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user