Update algorithm and API documentation for smoking recovery and motivation features
- Added unified backend calculations for health recovery, savings, and motivation generation in the algorithm documentation. - Updated API documentation to include new endpoints for retrieving statistics and motivation messages, enhancing clarity on data retrieval processes. - Revised product documentation to reflect changes in API usage for health recovery and savings calculations, ensuring consistency across all related files.
This commit is contained in:
@@ -202,7 +202,9 @@ function calculateDailyTarget(baseline, stage, dayInStage) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 健康恢复计算
|
## 5. 健康恢复计算(后端统一)
|
||||||
|
|
||||||
|
接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`)
|
||||||
|
|
||||||
基于医学研究的恢复时间线:
|
基于医学研究的恢复时间线:
|
||||||
|
|
||||||
@@ -236,7 +238,9 @@ function calculateLungRecovery(smokeFreeMinutes) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 省钱计算
|
## 6. 省钱计算(后端统一)
|
||||||
|
|
||||||
|
接口:`GET /api/v1/smoke/stats?range=week|month|year`(详见 `docs/smoke/API.md`)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// utils/money.js
|
// utils/money.js
|
||||||
@@ -250,7 +254,9 @@ function calculateMoneySaved(packPriceCent, cigsPerPack, baselineCigsPerDay, act
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 激励语生成
|
## 7. 激励语生成(后端统一)
|
||||||
|
|
||||||
|
接口:`GET /api/v1/smoke/motivation`(详见 `docs/smoke/API.md`)
|
||||||
|
|
||||||
根据用户状态生成不同的激励语:
|
根据用户状态生成不同的激励语:
|
||||||
|
|
||||||
|
|||||||
@@ -433,3 +433,83 @@ AI 生成说明:
|
|||||||
- `resisted_count`:今日克制次数(`level=0 && num=0`)。
|
- `resisted_count`:今日克制次数(`level=0 && num=0`)。
|
||||||
- `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。
|
- `reduced_from_yesterday`:较昨日减少的支数(允许为负数;为负时表示“今天超出昨日”)。
|
||||||
- `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。
|
- `exceeded_yesterday`:是否超出昨日(`true` 表示今天超出昨日,前端可用作单独标识)。
|
||||||
|
|
||||||
|
## 14) 数据统计分析(趋势 + 健康 + 省钱)
|
||||||
|
|
||||||
|
`GET /api/v1/smoke/stats?range=week|month|year&date=2026-01-07`
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- `range`:`week|month|year`,默认 `week`
|
||||||
|
- `date`:锚点日期(`YYYY-MM-DD`),默认今天
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 用于“统计页”一屏数据整合(趋势、均值、环比、健康恢复、省钱、连续记录、已拒绝次数)。
|
||||||
|
- `trend_unit`:`day` 或 `month`,用于前端图表横轴显示。
|
||||||
|
|
||||||
|
成功响应(示例):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"range": "week",
|
||||||
|
"start": "2026-01-01",
|
||||||
|
"end": "2026-01-07",
|
||||||
|
"trend_unit": "day",
|
||||||
|
"trend": [
|
||||||
|
{ "label": "2026-01-01", "count": 2 },
|
||||||
|
{ "label": "2026-01-02", "count": 1 },
|
||||||
|
{ "label": "2026-01-03", "count": 0 },
|
||||||
|
{ "label": "2026-01-04", "count": 0 },
|
||||||
|
{ "label": "2026-01-05", "count": 3 },
|
||||||
|
{ "label": "2026-01-06", "count": 0 },
|
||||||
|
{ "label": "2026-01-07", "count": 0 }
|
||||||
|
],
|
||||||
|
"daily_average": 4,
|
||||||
|
"change_percent": -20,
|
||||||
|
"money": {
|
||||||
|
"available": true,
|
||||||
|
"pack_price_cent": 2500,
|
||||||
|
"cigs_per_pack": 20,
|
||||||
|
"expected_total": 140,
|
||||||
|
"actual_total": 92,
|
||||||
|
"saved_cent": 6000
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"available": true,
|
||||||
|
"smoke_free_minutes": 420,
|
||||||
|
"lung_recovery_percent": 12,
|
||||||
|
"milestones": [
|
||||||
|
{ "name": "心率血压恢复正常", "minutes": 20, "reached": true },
|
||||||
|
{ "name": "血氧水平恢复", "minutes": 480, "reached": false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"streak_days": 12,
|
||||||
|
"resisted_total": 24
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
- `change_percent`:与上一个同周期对比的变化比例(可为负)。
|
||||||
|
- `money.available=false`:表示缺少 `baseline_cigs_per_day` 或 `pack_price_cent`。
|
||||||
|
- `health.available=false`:表示无历史记录。
|
||||||
|
|
||||||
|
## 15) 激励语(后端统一生成)
|
||||||
|
|
||||||
|
`GET /api/v1/smoke/motivation`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- 基于当日数据(如 `today_count`、`resisted_count`、`last_smoke_at`)与 `quit_motivations` 生成一句激励语。
|
||||||
|
|
||||||
|
成功响应(示例):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"message": "今天的表现很稳,继续保持!记住你的目标:身体健康。",
|
||||||
|
"type": "encourage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
+9
-8
@@ -32,6 +32,7 @@
|
|||||||
| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` |
|
| 下次建议时间 | 显示建议的下次抽烟时间点 | `GET /next_smoke_time` |
|
||||||
| 今日已抽 | X / 目标数,较昨日 ±N | `next_smoke_time.today_count` + `next_smoke_time.reduced_from_yesterday`(可为负) + `next_smoke_time.exceeded_yesterday`(标识“超出昨日”) |
|
| 今日已抽 | X / 目标数,较昨日 ±N | `next_smoke_time.today_count` + `next_smoke_time.reduced_from_yesterday`(可为负) + `next_smoke_time.exceeded_yesterday`(标识“超出昨日”) |
|
||||||
| 烟瘾发作已抵抗 | 忍住次数统计 | `next_smoke_time.resisted_count` |
|
| 烟瘾发作已抵抗 | 忍住次数统计 | `next_smoke_time.resisted_count` |
|
||||||
|
| 激励语 | 当天一句话鼓励 | `GET /motivation` |
|
||||||
| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` |
|
| 记录抽烟按钮 | 快速记录一次抽烟 | `POST /logs` |
|
||||||
| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` |
|
| 想抽忍住了按钮 | 记录成功抵抗 | `POST /logs/resisted` |
|
||||||
|
|
||||||
@@ -46,15 +47,15 @@
|
|||||||
|
|
||||||
| 功能 | 说明 | 数据来源 |
|
| 功能 | 说明 | 数据来源 |
|
||||||
|------|------|----------|
|
|------|------|----------|
|
||||||
| 周/月/年切换 | 切换统计时间范围 | `GET /dashboard?start=&end=` |
|
| 周/月/年切换 | 切换统计时间范围 | `GET /stats?range=week|month|year` |
|
||||||
| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` |
|
| 每周洞察 | AI 分析本周表现 | `GET /ai/advice` |
|
||||||
| 吸烟趋势图 | 柱状图展示每日吸烟量 | `dashboard.weekly` |
|
| 吸烟趋势图 | 柱状图展示每日吸烟量 | `stats.trend` |
|
||||||
| 趋势对比 | 较上周减少 X% | 本地计算 |
|
| 趋势对比 | 较上周期变化 | `stats.change_percent` |
|
||||||
| 日均吸烟量 | 统计周期内日均值 | 本地计算 |
|
| 日均吸烟量 | 统计周期内日均值 | `stats.daily_average` |
|
||||||
| 节省金额 | 基于减少量 × 单价计算 | profile + logs |
|
| 节省金额 | 后端统一计算 | `stats.money` |
|
||||||
| 肺部功能恢复 | 根据戒烟天数估算 | 固定公式 |
|
| 肺部功能恢复 | 后端统一计算 | `stats.health` |
|
||||||
| 连续记录天数 | 用户活跃天数 | logs 统计 |
|
| 连续记录天数 | 用户活跃天数 | `stats.streak_days` |
|
||||||
| 已拒绝次数 | 累计忍住次数 | `level=0,num=0` 统计 |
|
| 已拒绝次数 | 累计忍住次数 | `stats.resisted_total` |
|
||||||
|
|
||||||
### 2.3 AI 助手页 (ai_quit_assistant)
|
### 2.3 AI 助手页 (ai_quit_assistant)
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,10 @@
|
|||||||
|
|
||||||
## 5. 页面能力清单
|
## 5. 页面能力清单
|
||||||
- 首页:上次实际抽烟时间(用于计时)、今日累计、今日克制、较昨日增减(可为负并标识“超出昨日”)、下次建议时间(默认/AI)、时间节点列表、快速入口(抽烟/忍住)。
|
- 首页:上次实际抽烟时间(用于计时)、今日累计、今日克制、较昨日增减(可为负并标识“超出昨日”)、下次建议时间(默认/AI)、时间节点列表、快速入口(抽烟/忍住)。
|
||||||
|
- 首页激励语:`GET /api/v1/smoke/motivation`
|
||||||
- 记录页:快速添加抽烟、快速忍住、补录真实时间 `smoke_at`。
|
- 记录页:快速添加抽烟、快速忍住、补录真实时间 `smoke_at`。
|
||||||
- 列表页:按日期筛选、分页、区分“抽烟/忍住”标签、支持编辑/删除。
|
- 列表页:按日期筛选、分页、区分“抽烟/忍住”标签、支持编辑/删除。
|
||||||
- 看板页:周视图/区间视图,展示每日支数与 `minutes_since_last`。
|
- 看板/统计页:使用 `GET /api/v1/smoke/stats?range=week|month|year` 获取趋势、均值、环比、健康与省钱等数据。
|
||||||
- AI 建议页:每日建议展示(解锁后生成)。
|
- AI 建议页:每日建议展示(解锁后生成)。
|
||||||
- 基础信息页:补全/编辑基础烟量、动机/动力、作息。
|
- 基础信息页:补全/编辑基础烟量、动机/动力、作息。
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
|
|||||||
smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime)
|
smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime)
|
||||||
|
|
||||||
smoke.GET("/dashboard", smokeHandler.Dashboard)
|
smoke.GET("/dashboard", smokeHandler.Dashboard)
|
||||||
|
smoke.GET("/stats", smokeHandler.Stats)
|
||||||
smoke.POST("/logs", smokeHandler.Create)
|
smoke.POST("/logs", smokeHandler.Create)
|
||||||
smoke.POST("/logs/resisted", smokeHandler.Resist)
|
smoke.POST("/logs/resisted", smokeHandler.Resist)
|
||||||
smoke.GET("/logs", smokeHandler.List)
|
smoke.GET("/logs", smokeHandler.List)
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"wx_service/internal/middleware"
|
||||||
|
"wx_service/internal/model"
|
||||||
|
smokeservice "wx_service/internal/smoke/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *SmokeHandler) Stats(c *gin.Context) {
|
||||||
|
user, ok := middleware.CurrentUser(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("range", "week")))
|
||||||
|
asOf := time.Now().In(time.Local)
|
||||||
|
if v := strings.TrimSpace(c.Query("date")); v != "" {
|
||||||
|
parsed, err := time.ParseInLocation(dateLayout, v, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
asOf = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := buildStatsRequest(rangeType, asOf)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
profile, err := h.smokeProfileService.Get(c.Request.Context(), int(user.ID))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.smokeLogService.Stats(c.Request.Context(), int(user.ID), req, profile)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取统计数据失败,请稍后重试"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, model.Success(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStatsRequest(rangeType string, anchor time.Time) (smokeservice.SmokeStatsRequest, error) {
|
||||||
|
local := anchor.In(time.Local)
|
||||||
|
switch rangeType {
|
||||||
|
case "week":
|
||||||
|
start, end := weekRange(local)
|
||||||
|
return smokeservice.SmokeStatsRequest{
|
||||||
|
Range: "week",
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
PrevStart: start.AddDate(0, 0, -7),
|
||||||
|
PrevEnd: end.AddDate(0, 0, -7),
|
||||||
|
TrendUnit: "day",
|
||||||
|
AsOf: local,
|
||||||
|
}, nil
|
||||||
|
case "month":
|
||||||
|
start := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
end := start.AddDate(0, 1, 0).AddDate(0, 0, -1)
|
||||||
|
prevEnd := start.AddDate(0, 0, -1)
|
||||||
|
prevStart := time.Date(prevEnd.Year(), prevEnd.Month(), 1, 0, 0, 0, 0, time.Local)
|
||||||
|
return smokeservice.SmokeStatsRequest{
|
||||||
|
Range: "month",
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
PrevStart: prevStart,
|
||||||
|
PrevEnd: prevEnd,
|
||||||
|
TrendUnit: "day",
|
||||||
|
AsOf: local,
|
||||||
|
}, nil
|
||||||
|
case "year":
|
||||||
|
start := time.Date(local.Year(), time.January, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
end := time.Date(local.Year(), time.December, 31, 0, 0, 0, 0, time.Local)
|
||||||
|
prevStart := time.Date(local.Year()-1, time.January, 1, 0, 0, 0, 0, time.Local)
|
||||||
|
prevEnd := time.Date(local.Year()-1, time.December, 31, 0, 0, 0, 0, time.Local)
|
||||||
|
return smokeservice.SmokeStatsRequest{
|
||||||
|
Range: "year",
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
PrevStart: prevStart,
|
||||||
|
PrevEnd: prevEnd,
|
||||||
|
TrendUnit: "month",
|
||||||
|
AsOf: local,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return smokeservice.SmokeStatsRequest{}, errors.New("range 应为 week|month|year")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weekRange(anchor time.Time) (time.Time, time.Time) {
|
||||||
|
local := anchor.In(time.Local)
|
||||||
|
weekday := local.Weekday()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
smokemodel "wx_service/internal/smoke/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultCigsPerPack = 20
|
||||||
|
|
||||||
|
type SmokeStatsRequest struct {
|
||||||
|
Range string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
PrevStart time.Time
|
||||||
|
PrevEnd time.Time
|
||||||
|
TrendUnit string
|
||||||
|
AsOf time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeStatsResult struct {
|
||||||
|
Range string `json:"range"`
|
||||||
|
Start string `json:"start"`
|
||||||
|
End string `json:"end"`
|
||||||
|
TrendUnit string `json:"trend_unit"`
|
||||||
|
Trend []SmokeStatsTrend `json:"trend"`
|
||||||
|
DailyAverage int `json:"daily_average"`
|
||||||
|
ChangePercent int `json:"change_percent"`
|
||||||
|
Money SmokeStatsMoney `json:"money"`
|
||||||
|
Health SmokeStatsHealth `json:"health"`
|
||||||
|
StreakDays int `json:"streak_days"`
|
||||||
|
ResistedTotal int `json:"resisted_total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeStatsTrend struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeStatsMoney struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
PackPriceCent int `json:"pack_price_cent,omitempty"`
|
||||||
|
CigsPerPack int `json:"cigs_per_pack,omitempty"`
|
||||||
|
ExpectedTotal int `json:"expected_total,omitempty"`
|
||||||
|
ActualTotal int `json:"actual_total,omitempty"`
|
||||||
|
SavedCent int `json:"saved_cent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmokeStatsHealth struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
SmokeFreeMinutes int `json:"smoke_free_minutes,omitempty"`
|
||||||
|
LungRecoveryPercent int `json:"lung_recovery_percent,omitempty"`
|
||||||
|
Milestones []HealthMilestone `json:"milestones,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthMilestone struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Minutes int `json:"minutes"`
|
||||||
|
Reached bool `json:"reached"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) Stats(ctx context.Context, uid int, req SmokeStatsRequest, profile *smokemodel.SmokeUserProfile) (SmokeStatsResult, error) {
|
||||||
|
start := dateOnly(req.Start)
|
||||||
|
end := dateOnly(req.End)
|
||||||
|
trendUnit := req.TrendUnit
|
||||||
|
|
||||||
|
var (
|
||||||
|
trend []SmokeStatsTrend
|
||||||
|
total int64
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if trendUnit == "month" {
|
||||||
|
trend, total, err = s.loadMonthlyTrend(ctx, uid, start, end)
|
||||||
|
} else {
|
||||||
|
trend, total, err = s.loadDailyTrend(ctx, uid, start, end)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dayCount := daysBetweenInclusive(start, end)
|
||||||
|
dailyAvg := 0
|
||||||
|
if dayCount > 0 {
|
||||||
|
dailyAvg = int(math.Round(float64(total) / float64(dayCount)))
|
||||||
|
}
|
||||||
|
|
||||||
|
prevTotal, err := s.sumCigs(ctx, uid, req.PrevStart, req.PrevEnd)
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsResult{}, err
|
||||||
|
}
|
||||||
|
changePercent := 0
|
||||||
|
if prevTotal > 0 {
|
||||||
|
changePercent = int(math.Round((float64(total) - float64(prevTotal)) / float64(prevTotal) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
resistedTotal, err := s.countResisted(ctx, uid, start, end)
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
streakDays, err := s.computeStreakDays(ctx, uid, req.AsOf)
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
money := s.computeMoney(profile, int(total), dayCount)
|
||||||
|
health, err := s.computeHealth(ctx, uid, req.AsOf)
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return SmokeStatsResult{
|
||||||
|
Range: req.Range,
|
||||||
|
Start: start.Format("2006-01-02"),
|
||||||
|
End: end.Format("2006-01-02"),
|
||||||
|
TrendUnit: trendUnit,
|
||||||
|
Trend: trend,
|
||||||
|
DailyAverage: dailyAvg,
|
||||||
|
ChangePercent: changePercent,
|
||||||
|
Money: money,
|
||||||
|
Health: health,
|
||||||
|
StreakDays: streakDays,
|
||||||
|
ResistedTotal: resistedTotal,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) loadDailyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) {
|
||||||
|
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").
|
||||||
|
Order("smoke_time ASC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("load daily trend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64, len(rows))
|
||||||
|
var total int64
|
||||||
|
for _, row := range rows {
|
||||||
|
key := dateOnly(row.SmokeTime).Format("2006-01-02")
|
||||||
|
counts[key] = row.Total
|
||||||
|
total += row.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SmokeStatsTrend, 0, daysBetweenInclusive(start, end))
|
||||||
|
for day := start; !day.After(end); day = day.AddDate(0, 0, 1) {
|
||||||
|
label := day.Format("2006-01-02")
|
||||||
|
out = append(out, SmokeStatsTrend{
|
||||||
|
Label: label,
|
||||||
|
Count: int(counts[label]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) loadMonthlyTrend(ctx context.Context, uid int, start, end time.Time) ([]SmokeStatsTrend, int64, error) {
|
||||||
|
type monthlyCount struct {
|
||||||
|
Month string `gorm:"column:month"`
|
||||||
|
Total int64 `gorm:"column:total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []monthlyCount
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&smokemodel.SmokeLog{}).
|
||||||
|
Select("DATE_FORMAT(smoke_time, '%Y-%m') AS month, 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("month").
|
||||||
|
Order("month ASC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("load monthly trend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64, len(rows))
|
||||||
|
var total int64
|
||||||
|
for _, row := range rows {
|
||||||
|
counts[row.Month] = row.Total
|
||||||
|
total += row.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]SmokeStatsTrend, 0, 12)
|
||||||
|
for cursor := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.Local); !cursor.After(end); cursor = cursor.AddDate(0, 1, 0) {
|
||||||
|
label := cursor.Format("2006-01")
|
||||||
|
out = append(out, SmokeStatsTrend{
|
||||||
|
Label: label,
|
||||||
|
Count: int(counts[label]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) sumCigs(ctx context.Context, uid int, start, end time.Time) (int64, error) {
|
||||||
|
start = dateOnly(start)
|
||||||
|
end = dateOnly(end)
|
||||||
|
var total int64
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&smokemodel.SmokeLog{}).
|
||||||
|
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")).
|
||||||
|
Select("COALESCE(SUM(num), 0)").
|
||||||
|
Scan(&total).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("sum cigs: %w", err)
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end time.Time) (int, error) {
|
||||||
|
start = dateOnly(start)
|
||||||
|
end = dateOnly(end)
|
||||||
|
var count int64
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&smokemodel.SmokeLog{}).
|
||||||
|
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||||
|
Where("level = 0 AND num = 0").
|
||||||
|
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), end.Format("2006-01-02")).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("count resisted: %w", err)
|
||||||
|
}
|
||||||
|
return int(count), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) {
|
||||||
|
asOf = dateOnly(asOf)
|
||||||
|
start := asOf.AddDate(0, 0, -400)
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
SmokeTime time.Time `gorm:"column:smoke_time"`
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Model(&smokemodel.SmokeLog{}).
|
||||||
|
Distinct("smoke_time").
|
||||||
|
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||||
|
Where("smoke_time BETWEEN ? AND ?", start.Format("2006-01-02"), asOf.Format("2006-01-02")).
|
||||||
|
Order("smoke_time DESC").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("load streak days: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
daySet := make(map[string]bool, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
daySet[dateOnly(r.SmokeTime).Format("2006-01-02")] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
streak := 0
|
||||||
|
for day := asOf; ; day = day.AddDate(0, 0, -1) {
|
||||||
|
key := day.Format("2006-01-02")
|
||||||
|
if !daySet[key] {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
streak++
|
||||||
|
}
|
||||||
|
return streak, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) computeMoney(profile *smokemodel.SmokeUserProfile, actualTotal int, dayCount int) SmokeStatsMoney {
|
||||||
|
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 || dayCount <= 0 {
|
||||||
|
return SmokeStatsMoney{Available: false}
|
||||||
|
}
|
||||||
|
expectedTotal := profile.BaselineCigsPerDay * dayCount
|
||||||
|
savedCigs := expectedTotal - actualTotal
|
||||||
|
savedPacks := float64(savedCigs) / float64(defaultCigsPerPack)
|
||||||
|
savedCent := int(math.Round(savedPacks * float64(profile.PackPriceCent)))
|
||||||
|
return SmokeStatsMoney{
|
||||||
|
Available: true,
|
||||||
|
PackPriceCent: profile.PackPriceCent,
|
||||||
|
CigsPerPack: defaultCigsPerPack,
|
||||||
|
ExpectedTotal: expectedTotal,
|
||||||
|
ActualTotal: actualTotal,
|
||||||
|
SavedCent: savedCent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) computeHealth(ctx context.Context, uid int, asOf time.Time) (SmokeStatsHealth, error) {
|
||||||
|
lastSmokeAt, err := s.loadLastActualSmokeAt(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return SmokeStatsHealth{}, err
|
||||||
|
}
|
||||||
|
if lastSmokeAt == nil {
|
||||||
|
return SmokeStatsHealth{Available: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
minutes := int(asOf.Sub(*lastSmokeAt).Minutes())
|
||||||
|
if minutes < 0 {
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return SmokeStatsHealth{
|
||||||
|
Available: true,
|
||||||
|
SmokeFreeMinutes: minutes,
|
||||||
|
LungRecoveryPercent: computeLungRecoveryPercent(minutes),
|
||||||
|
Milestones: buildHealthMilestones(minutes),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SmokeLogService) loadLastActualSmokeAt(ctx context.Context, uid int) (*time.Time, error) {
|
||||||
|
var last smokemodel.SmokeLog
|
||||||
|
if err := s.db.WithContext(ctx).
|
||||||
|
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||||
|
Where("NOT (level = 0 AND num = 0)").
|
||||||
|
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(1).
|
||||||
|
Take(&last).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("load last smoke log: %w", err)
|
||||||
|
}
|
||||||
|
if t, ok := lastEventTime(last); ok {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeLungRecoveryPercent(smokeFreeMinutes int) int {
|
||||||
|
days := float64(smokeFreeMinutes) / (24 * 60)
|
||||||
|
switch {
|
||||||
|
case days < 14:
|
||||||
|
return int(math.Round((days / 14) * 15))
|
||||||
|
case days < 30:
|
||||||
|
return int(math.Round(15 + ((days-14)/16)*15))
|
||||||
|
case days < 90:
|
||||||
|
return int(math.Round(30 + ((days-30)/60)*20))
|
||||||
|
default:
|
||||||
|
val := 50 + ((days-90)/275)*50
|
||||||
|
if val > 100 {
|
||||||
|
val = 100
|
||||||
|
}
|
||||||
|
return int(math.Round(val))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildHealthMilestones(smokeFreeMinutes int) []HealthMilestone {
|
||||||
|
steps := []struct {
|
||||||
|
name string
|
||||||
|
minutes int
|
||||||
|
}{
|
||||||
|
{"心率血压恢复正常", 20},
|
||||||
|
{"血氧水平恢复", 8 * 60},
|
||||||
|
{"心脏病风险开始下降", 24 * 60},
|
||||||
|
{"嗅觉味觉开始恢复", 48 * 60},
|
||||||
|
{"肺功能提升 15%", 14 * 24 * 60},
|
||||||
|
{"肺功能提升 30%", 30 * 24 * 60},
|
||||||
|
{"肺功能提升 50%", 90 * 24 * 60},
|
||||||
|
{"心脏病风险降低 50%", 365 * 24 * 60},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]HealthMilestone, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
out = append(out, HealthMilestone{
|
||||||
|
Name: step.name,
|
||||||
|
Minutes: step.minutes,
|
||||||
|
Reached: smokeFreeMinutes >= step.minutes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysBetweenInclusive(start, end time.Time) int {
|
||||||
|
start = dateOnly(start)
|
||||||
|
end = dateOnly(end)
|
||||||
|
if end.Before(start) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(end.Sub(start).Hours()/24) + 1
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user