From f1f77a4d3d270eb27a492c0407cdc3ff65dc2d61 Mon Sep 17 00:00:00 2001 From: nepiedg <806669289@qq.com> Date: Tue, 6 Jan 2026 00:17:51 +0000 Subject: [PATCH] Add dashboard and latest logs endpoints for smoke tracking - Introduced a new API endpoint `GET /api/v1/smoke/dashboard` to retrieve a summary of smoking statistics over a specified date range, including today's count and weekly breakdown. - Added `GET /api/v1/smoke/logs/latest` endpoint to fetch the most recent smoking logs with a configurable limit. - Updated the smoke handler and service to support the new functionality, including error handling for date parsing and limit validation. - Enhanced documentation to reflect the new API endpoints and their usage. --- docs/smoke/API.md | 72 +++++++++- internal/routes/smoke_routes.go | 2 + internal/smoke/handler/smoke_handler.go | 101 +++++++++++++- internal/smoke/service/smoke_log_service.go | 139 +++++++++++++++++++- 4 files changed, 304 insertions(+), 10 deletions(-) diff --git a/docs/smoke/API.md b/docs/smoke/API.md index 181e6ab..7e37e66 100644 --- a/docs/smoke/API.md +++ b/docs/smoke/API.md @@ -88,7 +88,71 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ } ``` -## 4) 更新记录 +## 4) 获取看板概览 + +`GET /api/v1/smoke/dashboard?start=2026-01-01&end=2026-01-07` + +参数: +- `start`:起始日期(含,格式 `YYYY-MM-DD`),默认“本周一” +- `end`:截止日期(含,格式 `YYYY-MM-DD`),默认“本周日”。若只传 `start`,`end` 默认为 `start + 6 天`。 + +成功响应示例: + +```json +{ + "code": 200, + "message": "success", + "data": { + "today_count": 6, + "minutes_since_last": 42, + "weekly": [ + { "date": "2026-01-01", "count": 2, "is_today": false }, + { "date": "2026-01-02", "count": 1, "is_today": false }, + { "date": "2026-01-03", "count": 0, "is_today": false }, + { "date": "2026-01-04", "count": 0, "is_today": false }, + { "date": "2026-01-05", "count": 3, "is_today": true }, + { "date": "2026-01-06", "count": 0, "is_today": false }, + { "date": "2026-01-07", "count": 0, "is_today": false } + ] + } +} +``` + +字段说明: +- `today_count`:当天吸烟总支数(累加 `num`) +- `minutes_since_last`:距最后一次抽烟的分钟数,通过最近一条 `smoke_at/smoke_time/createtime` 计算;若历史为空则字段不存在 +- `weekly`:起止日期内每日汇总,`count` 为当日总支数,`is_today` 标记当前日期(即便不在 `start/end` 范围内也会标记为 `false`) + +## 5) 最近记录列表(轻量版) + +`GET /api/v1/smoke/logs/latest?limit=20` + +参数: +- `limit`:返回条数,默认 `20`,最大 `100` + +成功响应示例: + +```json +{ + "code": 200, + "message": "success", + "data": { + "items": [ + { + "id": 123, + "smoke_time": "2026-01-05T00:00:00+08:00", + "smoke_at": "2026-01-05T09:12:00+08:00", + "remark": "压力大", + "level": 3, + "num": 2, + "createtime": 1736049120 + } + ] + } +} +``` + +## 6) 更新记录 `PUT /api/v1/smoke/logs/:id` @@ -109,7 +173,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ - 如果你想“清空 smoke_at”,请传空字符串:`{"smoke_at":""}`。 - 如果传 `null` 或者不传 `smoke_time`,后端会认为你没有修改该字段。 -## 5) 删除记录(软删除) +## 7) 删除记录(软删除) `DELETE /api/v1/smoke/logs/:id` @@ -125,7 +189,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ } ``` -## 6) 获取 AI 戒烟建议(会员 + 广告解锁并行) +## 8) 获取 AI 戒烟建议(会员 + 广告解锁并行) `GET /api/v1/smoke/ai/advice?date=2026-01-02` @@ -158,7 +222,7 @@ curl -X GET 'http://127.0.0.1:8080/api/v1/smoke/logs/5202' \ } ``` -## 7) 看广告解锁(用于非会员) +## 9) 看广告解锁(用于非会员) `POST /api/v1/smoke/ai/advice_unlocks` diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 12d793d..1f16c0e 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -10,8 +10,10 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. // 戒烟/抽烟记录(与 video 去水印功能在路由前缀上区分开) smoke := protected.Group("/smoke") { + smoke.GET("/dashboard", smokeHandler.Dashboard) smoke.POST("/logs", smokeHandler.Create) smoke.GET("/logs", smokeHandler.List) + smoke.GET("/logs/latest", smokeHandler.LatestLogs) smoke.GET("/logs/:id", smokeHandler.Get) smoke.PUT("/logs/:id", smokeHandler.Update) smoke.DELETE("/logs/:id", smokeHandler.Delete) diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 99618a0..b6eb0ba 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -34,9 +34,9 @@ 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"` + Remark string `json:"remark"` + Level int64 `json:"level"` + Num int `json:"num"` } func (h *SmokeHandler) Create(c *gin.Context) { @@ -161,6 +161,87 @@ func (h *SmokeHandler) List(c *gin.Context) { })) } +func (h *SmokeHandler) Dashboard(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + now := time.Now() + defaultStart, defaultEnd := defaultDashboardRange(now) + + startDate := defaultStart + startProvided := false + if v := c.Query("start"); v != "" { + parsed, err := time.ParseInLocation(dateLayout, v, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "start 格式错误,应为 YYYY-MM-DD")) + return + } + startDate = parsed + startProvided = true + } + + endDate := defaultEnd + if v := c.Query("end"); v != "" { + parsed, err := time.ParseInLocation(dateLayout, v, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "end 格式错误,应为 YYYY-MM-DD")) + return + } + endDate = parsed + } else if startProvided { + endDate = startDate.AddDate(0, 0, 6) + } + + if endDate.Before(startDate) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "end 不能早于 start")) + return + } + + result, err := h.smokeLogService.Dashboard(c.Request.Context(), int(user.ID), smokeservice.SmokeDashboardRequest{ + Start: startDate, + End: endDate, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取看板概览失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(result)) +} + +func (h *SmokeHandler) LatestLogs(c *gin.Context) { + user, ok := middleware.CurrentUser(c) + if !ok { + c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期")) + return + } + + limit, err := strconv.Atoi(c.DefaultQuery("limit", "20")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "limit 应为数字")) + return + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + items, err := h.smokeLogService.ListLatest(c.Request.Context(), int(user.ID), limit) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取最近记录失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "items": items, + })) +} + type updateSmokeLogRequest struct { SmokeTime *string `json:"smoke_time"` SmokeAt *string `json:"smoke_at"` @@ -265,3 +346,17 @@ func (h *SmokeHandler) Delete(c *gin.Context) { "deleted": true, })) } + +// defaultDashboardRange 返回“本周一到本周日”的日期范围,供看板默认使用。 +func defaultDashboardRange(now time.Time) (time.Time, time.Time) { + local := now.In(time.Local) + weekday := local.Weekday() + // 转为以周一为 0 + daysSinceMonday := int(weekday) - int(time.Monday) + if daysSinceMonday < 0 { + daysSinceMonday += 7 + } + start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -daysSinceMonday) + end := start.AddDate(0, 0, 6) + return start, end +} diff --git a/internal/smoke/service/smoke_log_service.go b/internal/smoke/service/smoke_log_service.go index 3720579..f7e1223 100644 --- a/internal/smoke/service/smoke_log_service.go +++ b/internal/smoke/service/smoke_log_service.go @@ -104,6 +104,26 @@ type ListSmokeLogsResult struct { PageSize int } +// SmokeDashboardRequest 定义了看板概览的时间范围(包含起止日期)。 +type SmokeDashboardRequest struct { + Start time.Time + End time.Time +} + +// SmokeDashboardResult 用于返回看板概览的关键指标。 +type SmokeDashboardResult struct { + TodayCount int `json:"today_count"` + MinutesSinceLast *int `json:"minutes_since_last,omitempty"` + Weekly []DashboardWeeklyStat `json:"weekly"` +} + +// DashboardWeeklyStat 表示某一天的抽烟支数以及是否为今天。 +type DashboardWeeklyStat struct { + Date string `json:"date"` + Count int `json:"count"` + IsToday bool `json:"is_today"` +} + func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRequest) (ListSmokeLogsResult, error) { page := req.Page if page <= 0 { @@ -151,6 +171,119 @@ func (s *SmokeLogService) List(ctx context.Context, uid int, req ListSmokeLogsRe }, nil } +func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashboardRequest) (SmokeDashboardResult, error) { + start := dateOnly(req.Start) + end := dateOnly(req.End) + + type dailyCount struct { + SmokeTime time.Time `gorm:"column:smoke_time"` + Total int64 `gorm:"column:total"` + } + + var rows []dailyCount + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Select("smoke_time, SUM(num) AS total"). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")). + Group("smoke_time"). + Find(&rows).Error; err != nil { + return SmokeDashboardResult{}, fmt.Errorf("aggregate smoke logs: %w", err) + } + + counts := make(map[string]int64, len(rows)) + for _, row := range rows { + key := dateOnly(row.SmokeTime).Format("2006-01-02") + counts[key] = row.Total + } + + today := dateOnly(time.Now()) + todayKey := today.Format("2006-01-02") + var todayCount int64 + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeLog{}). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0) AND smoke_time = ?", uid, todayKey). + Select("COALESCE(SUM(num), 0)"). + Scan(&todayCount).Error; err != nil { + return SmokeDashboardResult{}, fmt.Errorf("count today smoke logs: %w", err) + } + + var minutesSinceLast *int + var last smokemodel.SmokeLog + if err := s.db.WithContext(ctx). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Order("COALESCE(smoke_at, smoke_time, FROM_UNIXTIME(createtime)) DESC"). + Order("id DESC"). + Limit(1). + Take(&last).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return SmokeDashboardResult{}, fmt.Errorf("load last smoke log: %w", err) + } + } else { + if lastTime, ok := lastEventTime(last); ok { + diff := int(time.Since(lastTime).Minutes()) + if diff < 0 { + diff = 0 + } + minutesSinceLast = &diff + } + } + + var weekly []DashboardWeeklyStat + for day := start; !day.After(end); day = day.AddDate(0, 0, 1) { + key := day.Format("2006-01-02") + count := counts[key] + weekly = append(weekly, DashboardWeeklyStat{ + Date: key, + Count: int(count), + IsToday: key == todayKey, + }) + } + + return SmokeDashboardResult{ + TodayCount: int(todayCount), + MinutesSinceLast: minutesSinceLast, + Weekly: weekly, + }, nil +} + +func (s *SmokeLogService) ListLatest(ctx context.Context, uid int, limit int) ([]smokemodel.SmokeLog, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + 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"). + Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid). + Order("COALESCE(smoke_at, smoke_time, FROM_UNIXTIME(createtime)) DESC"). + Order("id DESC"). + Limit(limit). + Find(&items).Error; err != nil { + return nil, fmt.Errorf("list latest smoke logs: %w", err) + } + + return items, nil +} + +func lastEventTime(log smokemodel.SmokeLog) (time.Time, bool) { + if log.SmokeAt != nil { + return log.SmokeAt.In(time.Local), true + } + if log.SmokeTime != nil { + day := dateOnly(*log.SmokeTime) + return day, true + } + if log.CreateTime != nil { + return time.Unix(*log.CreateTime, 0).In(time.Local), true + } + return time.Time{}, false +} + type UpdateSmokeLogRequest struct { // SmokeTimeProvided 用于区分: // - false:前端没传 smoke_time(不修改) @@ -162,9 +295,9 @@ type UpdateSmokeLogRequest struct { // - true:前端传了 smoke_at(可以设置为具体时间,也可以清空为 NULL) SmokeAtProvided bool SmokeAt *time.Time - Remark *string - Level *int64 - Num *int + Remark *string + Level *int64 + Num *int } func (s *SmokeLogService) Update(ctx context.Context, uid int, id int, req UpdateSmokeLogRequest) (*smokemodel.SmokeLog, error) {