Files
wx_service/internal/smoke/service/smoke_stats_service.go
T
nepiedg fd097729d7 feat: 戒烟成就、梦想图标预设、打卡统计与依赖注入调整
- 成就系统、连续打卡天数计算、管理后台成就 CRUD
- 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口
- 管理后台梦想图标 CRUD;戒烟打卡 summary 修正
- 忽略根目录编译产物 /api

Made-with: Cursor
2026-04-04 14:55:50 +08:00

443 lines
12 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
}
trend = limitTrend(trend, 7)
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(ctx, uid, profile, int(total), start, end)
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 limitTrend(items []SmokeStatsTrend, max int) []SmokeStatsTrend {
if max <= 0 || len(items) <= max {
return items
}
lastIndex := len(items) - 1
out := make([]SmokeStatsTrend, 0, max)
seen := make(map[int]struct{}, max)
for i := 0; i < max; i++ {
pos := int(math.Round(float64(i) * float64(lastIndex) / float64(max-1)))
if _, ok := seen[pos]; ok {
continue
}
seen[pos] = struct{}{}
out = append(out, items[pos])
}
return out
}
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) GetStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) {
return s.computeStreakDays(ctx, uid, asOf)
}
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(ctx context.Context, uid int, profile *smokemodel.SmokeUserProfile, actualTotal int, start, end time.Time) SmokeStatsMoney {
if profile == nil || profile.BaselineCigsPerDay <= 0 || profile.PackPriceCent <= 0 {
return SmokeStatsMoney{Available: false}
}
activeDays, err := s.countLogDays(ctx, uid, start, end)
if err != nil {
return SmokeStatsMoney{Available: false}
}
if activeDays <= 0 {
return SmokeStatsMoney{
Available: true,
PackPriceCent: profile.PackPriceCent,
CigsPerPack: defaultCigsPerPack,
ExpectedTotal: 0,
ActualTotal: actualTotal,
SavedCent: 0,
}
}
expectedTotal := profile.BaselineCigsPerDay * activeDays
savedCigs := expectedTotal - actualTotal
if savedCigs < 0 {
savedCigs = 0
}
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) countLogDays(ctx context.Context, uid int, start, end time.Time) (int, error) {
start = dateOnly(start)
end = dateOnly(end)
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"), end.Format("2006-01-02")).
Find(&rows).Error; err != nil {
return 0, fmt.Errorf("count log days: %w", err)
}
return len(rows), nil
}
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("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
}