From 6e0a06cfcf8710e2cd3afeda8df2f03ec89d5c03 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Thu, 16 Apr 2026 11:06:14 +0800 Subject: [PATCH] feat(smoke): support reason tags on smoke logs --- docs/smoke/API.md | 8 ++- docs/sql/smoke.sql | 1 + internal/admin/handler/smoke_handler.go | 22 +++---- internal/admin/service/smoke_service.go | 40 ++++++++----- internal/smoke/handler/smoke_handler.go | 51 +++++++++------- internal/smoke/model/smoke_log.go | 3 +- internal/smoke/service/smoke_log_service.go | 40 ++++++++----- .../smoke/service/smoke_log_service_test.go | 58 +++++++++++++++++++ 8 files changed, 158 insertions(+), 65 deletions(-) diff --git a/docs/smoke/API.md b/docs/smoke/API.md index 54c833a..c1355c6 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -13,6 +13,7 @@ { "smoke_time": "2025-12-31", "smoke_at": "2025-12-31 08:30:00", + "reason_tags": ["stress", "social"], "remark": "压力大", "level": 2, "num": 3 @@ -22,6 +23,7 @@ 说明: - `smoke_time` 可选;不传则默认“当天”。 - `smoke_at` 可选;真实抽烟时间(格式 `YYYY-MM-DD HH:MM:SS`)。用于“按时间节点分析/AI 建议”;不传则可用 `createtime` 近似。 +- `reason_tags` 可选;结构化原因标签,传 JSON 数组,例如 `["stress","after_meal"]`。 - `level/num` 可选;不传时后端会按 `1` 处理。 - `POST /api/v1/smoke/logs` 仅用于“抽烟记录”,`num` 必须 `>0`。 - “想抽但忍住了”请使用 `POST /api/v1/smoke/logs/resisted`;系统以 `level=0 且 num=0` 作为“忍住”的判断条件。 @@ -32,7 +34,7 @@ curl 示例: curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer wx-session-key' \ - -d '{"smoke_time":"2025-12-31","smoke_at":"2025-12-31 08:30:00","remark":"压力大","level":2,"num":3}' + -d '{"smoke_time":"2025-12-31","smoke_at":"2025-12-31 08:30:00","reason_tags":["stress","social"],"remark":"压力大","level":2,"num":3}' ``` 成功响应示例(字段以实际为准): @@ -46,6 +48,7 @@ curl -X POST 'http://127.0.0.1:8080/api/v1/smoke/logs' \ "smoke_time": "2025-12-31T00:00:00+08:00", "smoke_at": "2025-12-31T08:30:00+08:00", "remark": "压力大", + "reason_tags": ["stress", "social"], "createtime": 1735600000, "updatetime": 1735600000, "deletetime": null, @@ -149,6 +152,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ "smoke_time": "2026-01-05T00:00:00+08:00", "smoke_at": "2026-01-05T09:12:00+08:00", "remark": "压力大", + "reason_tags": ["stress"], "level": 3, "num": 2, "createtime": 1736049120 @@ -168,6 +172,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ { "smoke_time": "2026-01-01", "smoke_at": "2026-01-01 21:10:00", + "reason_tags": ["social"], "remark": "聚会", "level": 3, "num": 1 @@ -464,6 +469,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ { "smoke_time": "2026-01-05", "smoke_at": "2026-01-05 10:20:00", + "reason_tags": ["deep_breath", "water"], "remark": "压力大,想抽但忍住了", "level": 0, "num": 0 diff --git a/docs/sql/smoke.sql b/docs/sql/smoke.sql index 0275dd5..7160e25 100644 --- a/docs/sql/smoke.sql +++ b/docs/sql/smoke.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS `fa_smoke_log` ( `smoke_time` date DEFAULT NULL COMMENT '抽烟时间', `smoke_at` datetime DEFAULT NULL COMMENT '真实抽烟时间(可补录,精确到时分秒;为空则可用 createtime 近似)', `remark` text COMMENT '抽烟原因', + `reason_tags` json DEFAULT NULL COMMENT '结构化原因标签(JSON数组)', `createtime` int(11) DEFAULT NULL COMMENT '创建时间', `updatetime` int(11) DEFAULT NULL COMMENT '修改时间', `deletetime` int(11) DEFAULT NULL COMMENT '删除时间', diff --git a/internal/admin/handler/smoke_handler.go b/internal/admin/handler/smoke_handler.go index cb9c1c6..06e8aa3 100644 --- a/internal/admin/handler/smoke_handler.go +++ b/internal/admin/handler/smoke_handler.go @@ -25,12 +25,13 @@ type smokeLogListQuery struct { } 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"` + UID *int `json:"uid"` + SmokeTime *string `json:"smoke_time"` + SmokeAt *string `json:"smoke_at"` + Remark *string `json:"remark"` + ReasonTags *smokemodel.StringSlice `json:"reason_tags"` + Level *int64 `json:"level"` + Num *int `json:"num"` } func (h *Handler) ListSmokeLogs(c *gin.Context) { @@ -167,10 +168,11 @@ func (h *Handler) DeleteSmokeLog(c *gin.Context) { func buildSmokeLogInput(req smokeLogUpsertRequest) (adminservice.SmokeLogUpsertInput, error) { input := adminservice.SmokeLogUpsertInput{ - UID: req.UID, - Remark: req.Remark, - Level: req.Level, - Num: req.Num, + UID: req.UID, + Remark: req.Remark, + ReasonTags: req.ReasonTags, + Level: req.Level, + Num: req.Num, } if req.SmokeTime != nil { parsed, err := parseDateOnlyRequired(*req.SmokeTime) diff --git a/internal/admin/service/smoke_service.go b/internal/admin/service/smoke_service.go index 5c4f5ad..7fd1fff 100644 --- a/internal/admin/service/smoke_service.go +++ b/internal/admin/service/smoke_service.go @@ -24,15 +24,16 @@ type ListSmokeLogsQuery struct { } 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"` + 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"` + ReasonTags smokemodel.StringSlice `json:"reason_tags,omitempty"` + Level int64 `json:"level"` + Num int `json:"num"` + CreateTime *int64 `json:"createtime,omitempty"` + UpdateTime *int64 `json:"updatetime,omitempty"` } type ListSmokeLogsResult struct { @@ -45,12 +46,13 @@ type ListSmokeLogsResult struct { // SmokeLogUpsertInput 用于新增与更新戒烟记录。 // 说明:更新时可只传部分字段(指针字段支持局部更新)。 type SmokeLogUpsertInput struct { - UID *int - SmokeTime **time.Time - SmokeAt **time.Time - Remark *string - Level *int64 - Num *int + UID *int + SmokeTime **time.Time + SmokeAt **time.Time + Remark *string + ReasonTags *smokemodel.StringSlice + Level *int64 + Num *int } func (s *Service) ListSmokeLogs(ctx context.Context, query ListSmokeLogsQuery) (*ListSmokeLogsResult, error) { @@ -93,6 +95,7 @@ func (s *Service) ListSmokeLogs(ctx context.Context, query ListSmokeLogsQuery) ( SmokeTime: row.SmokeTime, SmokeAt: row.SmokeAt, Remark: row.Remark, + ReasonTags: row.ReasonTags, Level: row.Level, Num: row.Num, CreateTime: row.CreateTime, @@ -126,6 +129,7 @@ func (s *Service) GetSmokeLog(ctx context.Context, id int) (*SmokeLogItem, error SmokeTime: row.SmokeTime, SmokeAt: row.SmokeAt, Remark: row.Remark, + ReasonTags: row.ReasonTags, Level: row.Level, Num: row.Num, CreateTime: row.CreateTime, @@ -164,6 +168,9 @@ func (s *Service) CreateSmokeLog(ctx context.Context, input SmokeLogUpsertInput) if input.Remark != nil { row.Remark = strings.TrimSpace(*input.Remark) } + if input.ReasonTags != nil { + row.ReasonTags = *input.ReasonTags + } if err := s.db.WithContext(ctx).Create(row).Error; err != nil { return nil, err @@ -189,6 +196,9 @@ func (s *Service) UpdateSmokeLog(ctx context.Context, id int, input SmokeLogUpse if input.Remark != nil { updates["remark"] = strings.TrimSpace(*input.Remark) } + if input.ReasonTags != nil { + updates["reason_tags"] = *input.ReasonTags + } if input.Level != nil { if *input.Level <= 0 { return nil, ErrInvalidInput diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 996618a..1dc7dab 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -14,6 +14,7 @@ import ( "wx_service/internal/middleware" "wx_service/internal/model" quitcheckinservice "wx_service/internal/quitcheckin/service" + smokemodel "wx_service/internal/smoke/model" smokeservice "wx_service/internal/smoke/service" ) @@ -58,10 +59,11 @@ type createSmokeLogRequest struct { // 只记录“日期”即可;如果不传,后端会按当天处理 SmokeTime string `json:"smoke_time"` // 真实抽烟时间(精确到时分秒,可补录) - SmokeAt string `json:"smoke_at"` - Remark string `json:"remark"` - Level *int64 `json:"level"` - Num *int `json:"num"` + SmokeAt string `json:"smoke_at"` + Remark string `json:"remark"` + ReasonTags smokemodel.StringSlice `json:"reason_tags"` + Level *int64 `json:"level"` + Num *int `json:"num"` } func (h *SmokeHandler) Create(c *gin.Context) { @@ -120,11 +122,12 @@ func (h *SmokeHandler) Create(c *gin.Context) { } record, err := h.smokeLogService.Create(c.Request.Context(), int(user.ID), smokeservice.CreateSmokeLogRequest{ - SmokeTime: smokeTime, - SmokeAt: smokeAt, - Remark: req.Remark, - Level: level, - Num: num, + SmokeTime: smokeTime, + SmokeAt: smokeAt, + Remark: req.Remark, + ReasonTags: req.ReasonTags, + Level: level, + Num: num, }) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建记录失败,请稍后重试")) @@ -135,9 +138,10 @@ func (h *SmokeHandler) Create(c *gin.Context) { } type resistedSmokeLogRequest struct { - SmokeTime string `json:"smoke_time"` - SmokeAt string `json:"smoke_at"` - Remark string `json:"remark"` + SmokeTime string `json:"smoke_time"` + SmokeAt string `json:"smoke_at"` + Remark string `json:"remark"` + ReasonTags smokemodel.StringSlice `json:"reason_tags"` } // Resist 表示“想抽但忍住了”:在 fa_smoke_log 中写入 level=0,num=0。 @@ -171,11 +175,12 @@ func (h *SmokeHandler) Resist(c *gin.Context) { } record, err := h.smokeLogService.Create(c.Request.Context(), int(user.ID), smokeservice.CreateSmokeLogRequest{ - SmokeTime: smokeTime, - SmokeAt: smokeAt, - Remark: req.Remark, - Level: 0, - Num: 0, + SmokeTime: smokeTime, + SmokeAt: smokeAt, + Remark: req.Remark, + ReasonTags: req.ReasonTags, + Level: 0, + Num: 0, }) if err != nil { c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建记录失败,请稍后重试")) @@ -331,11 +336,12 @@ func (h *SmokeHandler) LatestLogs(c *gin.Context) { } type updateSmokeLogRequest struct { - SmokeTime *string `json:"smoke_time"` - SmokeAt *string `json:"smoke_at"` - Remark *string `json:"remark"` - Level *int64 `json:"level"` - Num *int `json:"num"` + SmokeTime *string `json:"smoke_time"` + SmokeAt *string `json:"smoke_at"` + Remark *string `json:"remark"` + ReasonTags *smokemodel.StringSlice `json:"reason_tags"` + Level *int64 `json:"level"` + Num *int `json:"num"` } func (h *SmokeHandler) Update(c *gin.Context) { @@ -402,6 +408,7 @@ func (h *SmokeHandler) Update(c *gin.Context) { SmokeAtProvided: smokeAtProvided, SmokeAt: smokeAt, Remark: req.Remark, + ReasonTags: req.ReasonTags, Level: req.Level, Num: req.Num, }) diff --git a/internal/smoke/model/smoke_log.go b/internal/smoke/model/smoke_log.go index c3aed11..e73ac34 100644 --- a/internal/smoke/model/smoke_log.go +++ b/internal/smoke/model/smoke_log.go @@ -16,7 +16,8 @@ type SmokeLog struct { // smoke_at:真实抽烟时间(可补录,精确到时分秒) SmokeAt *time.Time `gorm:"column:smoke_at;type:datetime;comment:真实抽烟时间(精确到秒)" json:"smoke_at,omitempty"` - Remark string `gorm:"column:remark;type:text;comment:原因/备注" json:"remark,omitempty"` + Remark string `gorm:"column:remark;type:text;comment:原因/备注" json:"remark,omitempty"` + ReasonTags StringSlice `gorm:"column:reason_tags;type:json;comment:结构化原因标签(JSON数组)" json:"reason_tags,omitempty"` // createtime/updatetime/deletetime:秒级 Unix 时间戳(与 gorm 默认字段不同) CreateTime *int64 `gorm:"column:createtime;comment:创建时间(秒)" json:"createtime,omitempty"` diff --git a/internal/smoke/service/smoke_log_service.go b/internal/smoke/service/smoke_log_service.go index 4b7c9f9..ddd9abb 100644 --- a/internal/smoke/service/smoke_log_service.go +++ b/internal/smoke/service/smoke_log_service.go @@ -21,16 +21,17 @@ type SmokeLogService struct { // smokeLogCreateRow 用于写入 fa_smoke_log,避免 SmokeLog 的 default 标签覆盖 0 值。 type smokeLogCreateRow struct { - ID int `gorm:"column:id;primaryKey;autoIncrement"` - UID int `gorm:"column:uid"` - SmokeTime *time.Time `gorm:"column:smoke_time"` - SmokeAt *time.Time `gorm:"column:smoke_at"` - Remark string `gorm:"column:remark"` - CreateTime *int64 `gorm:"column:createtime"` - UpdateTime *int64 `gorm:"column:updatetime"` - DeleteTime *int64 `gorm:"column:deletetime"` - Level *int64 `gorm:"column:level"` - Num *int `gorm:"column:num"` + ID int `gorm:"column:id;primaryKey;autoIncrement"` + UID int `gorm:"column:uid"` + SmokeTime *time.Time `gorm:"column:smoke_time"` + SmokeAt *time.Time `gorm:"column:smoke_at"` + Remark string `gorm:"column:remark"` + ReasonTags smokemodel.StringSlice `gorm:"column:reason_tags"` + CreateTime *int64 `gorm:"column:createtime"` + UpdateTime *int64 `gorm:"column:updatetime"` + DeleteTime *int64 `gorm:"column:deletetime"` + Level *int64 `gorm:"column:level"` + Num *int `gorm:"column:num"` } func (smokeLogCreateRow) TableName() string { @@ -42,11 +43,12 @@ func NewSmokeLogService(db *gorm.DB) *SmokeLogService { } type CreateSmokeLogRequest struct { - SmokeTime *time.Time - SmokeAt *time.Time - Remark string - Level int64 - Num int + SmokeTime *time.Time + SmokeAt *time.Time + Remark string + ReasonTags smokemodel.StringSlice + Level int64 + Num int } func (s *SmokeLogService) Create(ctx context.Context, uid int, req CreateSmokeLogRequest) (*smokemodel.SmokeLog, error) { @@ -82,6 +84,7 @@ func (s *SmokeLogService) Create(ctx context.Context, uid int, req CreateSmokeLo SmokeTime: smokeTime, SmokeAt: smokeAt, Remark: req.Remark, + ReasonTags: req.ReasonTags, CreateTime: &createTime, UpdateTime: &updateTime, Level: &level, @@ -98,6 +101,7 @@ func (s *SmokeLogService) Create(ctx context.Context, uid int, req CreateSmokeLo SmokeTime: insert.SmokeTime, SmokeAt: insert.SmokeAt, Remark: insert.Remark, + ReasonTags: insert.ReasonTags, CreateTime: insert.CreateTime, UpdateTime: insert.UpdateTime, DeleteTime: insert.DeleteTime, @@ -393,7 +397,7 @@ func (s *SmokeLogService) ListLatest(ctx context.Context, uid int, limit int) ([ var items []smokemodel.SmokeLog if err := s.db.WithContext(ctx). Model(&smokemodel.SmokeLog{}). - Select("id, uid, smoke_time, smoke_at, remark, level, num, createtime, updatetime, deletetime"). + Select("id, uid, smoke_time, smoke_at, remark, reason_tags, level, num, createtime, updatetime, deletetime"). Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC"). Order("id DESC"). @@ -431,6 +435,7 @@ type UpdateSmokeLogRequest struct { SmokeAtProvided bool SmokeAt *time.Time Remark *string + ReasonTags *smokemodel.StringSlice Level *int64 Num *int } @@ -455,6 +460,9 @@ func (s *SmokeLogService) Update(ctx context.Context, uid int, id int, req Updat if req.Remark != nil { updates["remark"] = *req.Remark } + if req.ReasonTags != nil { + updates["reason_tags"] = *req.ReasonTags + } if req.Level != nil { if *req.Level < 0 { updates["level"] = int64(1) diff --git a/internal/smoke/service/smoke_log_service_test.go b/internal/smoke/service/smoke_log_service_test.go index bf789a3..c4b608d 100644 --- a/internal/smoke/service/smoke_log_service_test.go +++ b/internal/smoke/service/smoke_log_service_test.go @@ -29,6 +29,7 @@ CREATE TABLE fa_smoke_log ( smoke_time DATE NULL, smoke_at DATETIME NULL, remark TEXT, + reason_tags TEXT, createtime INTEGER, updatetime INTEGER, deletetime INTEGER, @@ -68,3 +69,60 @@ func TestSmokeLogServiceCreateKeepsZeroForResisted(t *testing.T) { } } +func TestSmokeLogServiceCreatePersistsReasonTags(t *testing.T) { + t.Parallel() + + db := setupSmokeLogServiceTestDB(t) + svc := NewSmokeLogService(db) + + smokeAt := time.Date(2026, 3, 4, 8, 30, 0, 0, time.Local) + _, err := svc.Create(context.Background(), 1002, CreateSmokeLogRequest{ + SmokeAt: &smokeAt, + Remark: "压力大;会后补了一根", + ReasonTags: smokemodel.StringSlice{"stress", "social"}, + Level: 3, + Num: 1, + }) + if err != nil { + t.Fatalf("create log with reason tags: %v", err) + } + + var got smokemodel.SmokeLog + if err := db.Where("uid = ?", 1002).Order("id DESC").First(&got).Error; err != nil { + t.Fatalf("load created record: %v", err) + } + + if len(got.ReasonTags) != 2 || got.ReasonTags[0] != "stress" || got.ReasonTags[1] != "social" { + t.Fatalf("created reason_tags=%v, want=[stress social]", got.ReasonTags) + } +} + +func TestSmokeLogServiceUpdatePersistsReasonTags(t *testing.T) { + t.Parallel() + + db := setupSmokeLogServiceTestDB(t) + svc := NewSmokeLogService(db) + + smokeAt := time.Date(2026, 3, 4, 9, 0, 0, 0, time.Local) + record, err := svc.Create(context.Background(), 1003, CreateSmokeLogRequest{ + SmokeAt: &smokeAt, + Remark: "old", + Level: 2, + Num: 1, + }) + if err != nil { + t.Fatalf("create seed log: %v", err) + } + + reasonTags := smokemodel.StringSlice{"after_meal", "other"} + updated, err := svc.Update(context.Background(), 1003, record.ID, UpdateSmokeLogRequest{ + ReasonTags: &reasonTags, + }) + if err != nil { + t.Fatalf("update reason_tags: %v", err) + } + + if len(updated.ReasonTags) != 2 || updated.ReasonTags[0] != "after_meal" || updated.ReasonTags[1] != "other" { + t.Fatalf("updated reason_tags=%v, want=[after_meal other]", updated.ReasonTags) + } +}