c9ebfd5873
- 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.
382 lines
11 KiB
Go
382 lines
11 KiB
Go
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
|
|
}
|