package service import ( "context" "errors" "fmt" "sort" "strings" "time" quitmodel "wx_service/internal/quitcheckin/model" "gorm.io/gorm" ) var ( // ErrProfileNotFound 表示用户尚未完成基础资料配置。 ErrProfileNotFound = errors.New("未找到无烟打卡资料") // ErrAlreadyRelapsed 表示当天已经记录过复吸,不能重复记录。 ErrAlreadyRelapsed = errors.New("当天已记录复吸") // ErrRewardGoalNotFound 表示指定梦想目标不存在。 ErrRewardGoalNotFound = errors.New("梦想目标不存在") // ErrInvalidRange 表示统计范围不在允许值内。 ErrInvalidRange = errors.New("统计范围不合法") ) const ( dateLayout = "2006-01-02" timeOnlyLayout = "15:04" resetRule = "relapse_clears_streak" ) var milestoneDays = []int{1, 3, 7, 14, 21, 30, 60, 90, 180, 365} type Service struct { db *gorm.DB } // NewService 创建无烟打卡 V2 服务。 func NewService(db *gorm.DB) *Service { return &Service{db: db} } // ProfileView 表示“我的”页面所需的资料和旅程摘要。 type ProfileView struct { Profile *ProfileResult `json:"profile"` Journey JourneyResult `json:"journey"` } // ProfileResult 表示用户基础资料。 type ProfileResult struct { UserID int `json:"user_id"` Nickname string `json:"nickname,omitempty"` AvatarURL string `json:"avatar_url,omitempty"` QuitStartDate string `json:"quit_start_date"` PackPriceCent int `json:"pack_price_cent"` BaselineCigsPerDay int `json:"baseline_cigs_per_day"` Motivation string `json:"motivation,omitempty"` NotifyTime string `json:"notify_time,omitempty"` ResetRule string `json:"reset_rule"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // JourneyResult 表示当前戒烟旅程状态。 type JourneyResult struct { JourneyID uint `json:"journey_id"` UserID int `json:"user_id"` StartDate string `json:"start_date"` CurrentStreakDays int `json:"current_streak_days"` MaxStreakDays int `json:"max_streak_days"` LastCheckinDate *string `json:"last_checkin_date"` LastRelapseAt *string `json:"last_relapse_at"` Status string `json:"status"` } // UpsertProfileRequest 表示资料保存请求。 type UpsertProfileRequest struct { QuitStartDate *time.Time PackPriceCent *int BaselineCigsPerDay *int Motivation *string NotifyTime *string } // DailyStatusResult 表示某一天的打卡状态。 type DailyStatusResult struct { Date string `json:"date"` Status string `json:"status"` CheckinAt *string `json:"checkin_at"` RelapsedAt *string `json:"relapsed_at"` RelapseNum *int `json:"relapse_num"` Note *string `json:"note"` } // SummaryResult 表示首页和统计页共用的汇总数据。 type SummaryResult struct { CurrentStreakDays int `json:"current_streak_days"` MaxStreakDays int `json:"max_streak_days"` MilestoneDays int `json:"milestone_days"` DaysToNextMilestone int `json:"days_to_next_milestone"` SavedMoneyCent int `json:"saved_money_cent"` AvoidedCigs int `json:"avoided_cigs"` AvoidedCigsMode string `json:"avoided_cigs_mode"` HealthRecoveryPercent int `json:"health_recovery_percent"` } // RewardGoalResult 表示梦想目标展示数据。 type RewardGoalResult struct { ID uint `json:"id"` UserID int `json:"user_id"` Title string `json:"title"` TargetAmountCent int `json:"target_amount_cent"` CurrentAmountCent int `json:"current_amount_cent"` ProgressPercent int `json:"progress_percent"` CoverImage string `json:"cover_image,omitempty"` Status string `json:"status"` CompletedAt *string `json:"completed_at"` CreatedAt string `json:"created_at"` } // HomeResult 表示首页聚合返回数据。 type HomeResult struct { DailyStatus DailyStatusResult `json:"daily_status"` Summary SummaryResult `json:"summary"` Goal *RewardGoalResult `json:"goal,omitempty"` Badges map[string]int `json:"badges"` } // CheckinRequest 表示打卡请求参数。 type CheckinRequest struct { Date time.Time Note string } // CheckinResult 表示打卡后的返回结果。 type CheckinResult struct { DailyStatus DailyStatusResult `json:"daily_status"` Summary SummaryResult `json:"summary"` } // RelapseRequest 表示复吸记录请求参数。 type RelapseRequest struct { Date time.Time RelapseAt time.Time RelapseNum int Reason string Note string } // RelapseEventResult 表示复吸事件返回数据。 type RelapseEventResult struct { ID uint `json:"id"` UserID int `json:"user_id"` Date string `json:"date"` RelapseAt string `json:"relapse_at"` RelapseNum int `json:"relapse_num"` Reason string `json:"reason,omitempty"` Note string `json:"note,omitempty"` AffectStreak bool `json:"affect_streak"` CreatedAt string `json:"created_at"` } // RelapseResult 表示记录复吸后的聚合结果。 type RelapseResult struct { DailyStatus DailyStatusResult `json:"daily_status"` RelapseEvent RelapseEventResult `json:"relapse_event"` Summary SummaryResult `json:"summary"` } // StatsOverviewResult 表示统计页概览数据。 type StatsOverviewResult struct { Range string `json:"range"` Summary SummaryResult `json:"summary"` Trend []TrendItemResult `json:"trend"` HealthMilestones []HealthMilestone `json:"health_milestones"` } // TrendItemResult 表示趋势图中的单日数据点。 type TrendItemResult struct { Date string `json:"date"` Status string `json:"status"` RelapseNum int `json:"relapse_num"` } // HealthMilestone 表示健康恢复里程碑。 type HealthMilestone struct { Code string `json:"code"` Title string `json:"title"` Percent int `json:"percent"` Unlocked bool `json:"unlocked"` } // BadgeResult 表示勋章展示数据。 type BadgeResult struct { ID int `json:"id"` Code string `json:"code"` Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon,omitempty"` UnlockRule string `json:"unlock_rule"` Unlocked bool `json:"unlocked"` UnlockedAt *string `json:"unlocked_at"` ProgressPercent int `json:"progress_percent"` } // BadgeListResult 表示勋章列表返回结果。 type BadgeListResult struct { Items []BadgeResult `json:"items"` UnlockedCount int `json:"unlocked_count"` TotalCount int `json:"total_count"` } // RelapseListResult 表示复吸历史分页结果。 type RelapseListResult struct { Items []RelapseEventResult `json:"items"` Total int64 `json:"total"` Page int `json:"page"` PageSize int `json:"page_size"` } // RewardGoalListResult 表示梦想目标列表结果。 type RewardGoalListResult struct { Items []RewardGoalResult `json:"items"` Total int `json:"total"` } // CreateRewardGoalRequest 表示创建梦想目标请求。 type CreateRewardGoalRequest struct { Title string TargetAmountCent int CoverImage string } // UpdateRewardGoalRequest 表示更新梦想目标请求。 type UpdateRewardGoalRequest struct { Title *string TargetAmountCent *int CoverImage *string Status *string } // PosterGenerateRequest 表示海报生成请求。 type PosterGenerateRequest struct { TemplateCode string ShowFields []string } // PosterDataResult 表示海报预览数据。 type PosterDataResult struct { Nickname string `json:"nickname,omitempty"` StreakDays int `json:"streak_days"` SavedMoneyCent int `json:"saved_money_cent"` AvoidedCigs int `json:"avoided_cigs"` HealthRecoveryPercent int `json:"health_recovery_percent"` TemplateCode string `json:"template_code"` ShareTitle string `json:"share_title"` } // PosterGenerateResult 表示海报生成结果。 type PosterGenerateResult struct { TemplateCode string `json:"template_code"` ImageURL string `json:"image_url"` ShareTitle string `json:"share_title"` ShowFields []string `json:"show_fields"` } // GetProfile 返回用户资料与旅程概览。 func (s *Service) GetProfile(ctx context.Context, uid int, nickname, avatarURL string, now time.Time) (ProfileView, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return ProfileView{ Profile: nil, Journey: JourneyResult{ UserID: uid, CurrentStreakDays: 0, MaxStreakDays: 0, Status: "pending_profile", }, }, nil } return ProfileView{}, err } summary, lastCheckin, lastRelapse, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return ProfileView{}, err } return ProfileView{ Profile: &ProfileResult{ UserID: uid, Nickname: nickname, AvatarURL: avatarURL, QuitStartDate: formatDate(profile.QuitStartDate), PackPriceCent: profile.PackPriceCent, BaselineCigsPerDay: profile.BaselineCigsPerDay, Motivation: profile.Motivation, NotifyTime: profile.NotifyTime, ResetRule: profile.ResetRule, CreatedAt: profile.CreatedAt.Format(time.RFC3339), UpdatedAt: profile.UpdatedAt.Format(time.RFC3339), }, Journey: JourneyResult{ JourneyID: profile.ID, UserID: uid, StartDate: formatDate(profile.QuitStartDate), CurrentStreakDays: summary.CurrentStreakDays, MaxStreakDays: summary.MaxStreakDays, LastCheckinDate: lastCheckin, LastRelapseAt: lastRelapse, Status: "active", }, }, nil } // UpsertProfile 创建或更新用户资料。 func (s *Service) UpsertProfile(ctx context.Context, uid int, req UpsertProfileRequest, nickname, avatarURL string, now time.Time) (ProfileView, error) { var profile quitmodel.Profile err := s.db.WithContext(ctx).Where("uid = ?", uid).First(&profile).Error if err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { return ProfileView{}, err } profile = quitmodel.Profile{ UID: uid, ResetRule: resetRule, } } if req.QuitStartDate != nil { profile.QuitStartDate = normalizeDate(*req.QuitStartDate) } if req.PackPriceCent != nil { profile.PackPriceCent = *req.PackPriceCent } if req.BaselineCigsPerDay != nil { profile.BaselineCigsPerDay = *req.BaselineCigsPerDay } if req.Motivation != nil { profile.Motivation = strings.TrimSpace(*req.Motivation) } if req.NotifyTime != nil { profile.NotifyTime = strings.TrimSpace(*req.NotifyTime) } if profile.ResetRule == "" { profile.ResetRule = resetRule } if err := s.db.WithContext(ctx).Save(&profile).Error; err != nil { return ProfileView{}, err } return s.GetProfile(ctx, uid, nickname, avatarURL, now) } // Home 返回首页聚合数据。 func (s *Service) Home(ctx context.Context, uid int, now time.Time) (HomeResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return HomeResult{}, ErrProfileNotFound } return HomeResult{}, err } today := normalizeDate(now) dailyStatus, err := s.getOrBuildDailyStatus(ctx, uid, today, now) if err != nil { return HomeResult{}, err } summary, _, _, unlockedCount, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return HomeResult{}, err } activeGoal, err := s.firstActiveGoal(ctx, uid, summary.SavedMoneyCent) if err != nil { return HomeResult{}, err } return HomeResult{ DailyStatus: toDailyStatusResult(dailyStatus), Summary: summary, Goal: activeGoal, Badges: map[string]int{ "unlocked_count": unlockedCount, "total_count": len(milestoneDays), }, }, nil } // Checkin 执行当日打卡。 func (s *Service) Checkin(ctx context.Context, uid int, req CheckinRequest, now time.Time) (CheckinResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return CheckinResult{}, ErrProfileNotFound } return CheckinResult{}, err } date := normalizeDate(req.Date) var status quitmodel.DailyStatus err = s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return CheckinResult{}, err } checkinAt := now if errors.Is(err, gorm.ErrRecordNotFound) { status = quitmodel.DailyStatus{ UID: uid, Date: date, Status: quitmodel.DailyStatusCheckedIn, CheckInAt: &checkinAt, Note: strings.TrimSpace(req.Note), } if err := s.db.WithContext(ctx).Create(&status).Error; err != nil { return CheckinResult{}, err } } else { if status.Status == quitmodel.DailyStatusRelapsed { return CheckinResult{}, ErrAlreadyRelapsed } if status.Status != quitmodel.DailyStatusCheckedIn { status.Status = quitmodel.DailyStatusCheckedIn status.CheckInAt = &checkinAt status.Note = strings.TrimSpace(req.Note) if err := s.db.WithContext(ctx).Save(&status).Error; err != nil { return CheckinResult{}, err } } } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return CheckinResult{}, err } return CheckinResult{ DailyStatus: toDailyStatusResult(status), Summary: summary, }, nil } // Relapse 记录当日复吸并清零连续天数。 func (s *Service) Relapse(ctx context.Context, uid int, req RelapseRequest, now time.Time) (RelapseResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return RelapseResult{}, ErrProfileNotFound } return RelapseResult{}, err } date := normalizeDate(req.Date) var status quitmodel.DailyStatus err = s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return RelapseResult{}, err } if err == nil && status.Status == quitmodel.DailyStatusRelapsed { return RelapseResult{}, ErrAlreadyRelapsed } relapsedAt := req.RelapseAt if relapsedAt.IsZero() { relapsedAt = now } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if errors.Is(err, gorm.ErrRecordNotFound) { status = quitmodel.DailyStatus{ UID: uid, Date: date, Status: quitmodel.DailyStatusRelapsed, RelapsedAt: &relapsedAt, RelapseNum: req.RelapseNum, Reason: strings.TrimSpace(req.Reason), Note: strings.TrimSpace(req.Note), CheckInAt: nil, } if e := tx.Create(&status).Error; e != nil { return e } } else { status.Status = quitmodel.DailyStatusRelapsed status.CheckInAt = nil status.RelapsedAt = &relapsedAt status.RelapseNum = req.RelapseNum status.Reason = strings.TrimSpace(req.Reason) status.Note = strings.TrimSpace(req.Note) if e := tx.Save(&status).Error; e != nil { return e } } event := quitmodel.RelapseEvent{ UID: uid, Date: date, RelapseAt: relapsedAt, RelapseNum: req.RelapseNum, Reason: strings.TrimSpace(req.Reason), Note: strings.TrimSpace(req.Note), AffectStreak: true, } return tx.Create(&event).Error }) if err != nil { return RelapseResult{}, err } var event quitmodel.RelapseEvent if err := s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).Order("id desc").First(&event).Error; err != nil { return RelapseResult{}, err } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return RelapseResult{}, err } return RelapseResult{ DailyStatus: toDailyStatusResult(status), RelapseEvent: RelapseEventResult{ ID: event.ID, UserID: uid, Date: formatDate(event.Date), RelapseAt: event.RelapseAt.Format(time.RFC3339), RelapseNum: event.RelapseNum, Reason: event.Reason, Note: event.Note, AffectStreak: event.AffectStreak, CreatedAt: event.CreatedAt.Format(time.RFC3339), }, Summary: summary, }, nil } // StatsOverview 返回统计概览。 func (s *Service) StatsOverview(ctx context.Context, uid int, rangeName string, now time.Time) (StatsOverviewResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return StatsOverviewResult{}, ErrProfileNotFound } return StatsOverviewResult{}, err } rangeName = strings.TrimSpace(strings.ToLower(rangeName)) if rangeName == "" { rangeName = "week" } var start time.Time end := normalizeDate(now) switch rangeName { case "week": start = end.AddDate(0, 0, -6) case "month": start = end.AddDate(0, 0, -29) case "year": start = end.AddDate(0, 0, -364) default: return StatsOverviewResult{}, ErrInvalidRange } var dailyStatuses []quitmodel.DailyStatus if err := s.db.WithContext(ctx). Where("uid = ? AND date >= ? AND date <= ?", uid, start, end). Order("date asc"). Find(&dailyStatuses).Error; err != nil { return StatsOverviewResult{}, err } statusMap := make(map[string]quitmodel.DailyStatus, len(dailyStatuses)) for _, item := range dailyStatuses { statusMap[formatDate(item.Date)] = item } trend := make([]TrendItemResult, 0, daysBetweenInclusive(start, end)) for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 0, 1) { key := formatDate(cursor) item, ok := statusMap[key] if !ok { status := quitmodel.DailyStatusMissed if isSameDate(cursor, end) { status = quitmodel.DailyStatusPending } trend = append(trend, TrendItemResult{Date: key, Status: status, RelapseNum: 0}) continue } trend = append(trend, TrendItemResult{Date: key, Status: item.Status, RelapseNum: item.RelapseNum}) } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return StatsOverviewResult{}, err } return StatsOverviewResult{ Range: rangeName, Summary: summary, Trend: trend, HealthMilestones: []HealthMilestone{ { Code: "lung_recovery", Title: "肺部功能改善", Percent: summary.HealthRecoveryPercent, Unlocked: summary.CurrentStreakDays >= 7, }, }, }, nil } // ListBadges 返回勋章清单。 func (s *Service) ListBadges(ctx context.Context, uid int, now time.Time) (BadgeListResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return BadgeListResult{Items: []BadgeResult{}}, nil } return BadgeListResult{}, err } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return BadgeListResult{}, err } badges := make([]BadgeResult, 0, len(milestoneDays)) unlockedCount := 0 for idx, day := range milestoneDays { unlocked := summary.MaxStreakDays >= day progress := 0 if unlocked { progress = 100 unlockedCount++ } else if day > 0 { progress = minInt((summary.CurrentStreakDays*100)/day, 99) } var unlockedAt *string if unlocked { v := fmt.Sprintf("%d 天里程碑", day) unlockedAt = &v } badges = append(badges, BadgeResult{ ID: idx + 1, Code: fmt.Sprintf("streak_%d", day), Title: fmt.Sprintf("%d 天里程碑", day), Description: fmt.Sprintf("连续无烟 %d 天", day), UnlockRule: fmt.Sprintf("连续无烟 %d 天", day), Unlocked: unlocked, UnlockedAt: unlockedAt, ProgressPercent: progress, }) } return BadgeListResult{ Items: badges, UnlockedCount: unlockedCount, TotalCount: len(badges), }, nil } // ListRelapses 分页查询复吸历史。 func (s *Service) ListRelapses(ctx context.Context, uid, page, pageSize int) (RelapseListResult, error) { if page <= 0 { page = 1 } if pageSize <= 0 { pageSize = 20 } if pageSize > 100 { pageSize = 100 } var total int64 if err := s.db.WithContext(ctx).Model(&quitmodel.RelapseEvent{}).Where("uid = ?", uid).Count(&total).Error; err != nil { return RelapseListResult{}, err } var items []quitmodel.RelapseEvent if err := s.db.WithContext(ctx). Where("uid = ?", uid). Order("date desc, relapse_at desc, id desc"). Offset((page - 1) * pageSize). Limit(pageSize). Find(&items).Error; err != nil { return RelapseListResult{}, err } result := make([]RelapseEventResult, 0, len(items)) for _, item := range items { result = append(result, RelapseEventResult{ ID: item.ID, UserID: uid, Date: formatDate(item.Date), RelapseAt: item.RelapseAt.Format(time.RFC3339), RelapseNum: item.RelapseNum, Reason: item.Reason, Note: item.Note, AffectStreak: item.AffectStreak, CreatedAt: item.CreatedAt.Format(time.RFC3339), }) } return RelapseListResult{ Items: result, Total: total, Page: page, PageSize: pageSize, }, nil } // ListRewardGoals 查询梦想目标列表。 func (s *Service) ListRewardGoals(ctx context.Context, uid int, status string, now time.Time) (RewardGoalListResult, error) { status = strings.TrimSpace(strings.ToLower(status)) query := s.db.WithContext(ctx).Where("uid = ?", uid) if status != "" && status != "all" { query = query.Where("status = ?", status) } var goals []quitmodel.RewardGoal if err := query.Order("created_at desc").Find(&goals).Error; err != nil { return RewardGoalListResult{}, err } currentAmount, err := s.currentSavedMoney(ctx, uid, now) if err != nil { return RewardGoalListResult{}, err } items := make([]RewardGoalResult, 0, len(goals)) for _, goal := range goals { items = append(items, toRewardGoalResult(goal, uid, currentAmount)) } return RewardGoalListResult{ Items: items, Total: len(items), }, nil } // CreateRewardGoal 创建梦想目标。 func (s *Service) CreateRewardGoal(ctx context.Context, uid int, req CreateRewardGoalRequest, now time.Time) (RewardGoalResult, error) { goal := quitmodel.RewardGoal{ UID: uid, Title: strings.TrimSpace(req.Title), TargetAmountCent: req.TargetAmountCent, CoverImage: strings.TrimSpace(req.CoverImage), Status: quitmodel.RewardGoalStatusActive, } if err := s.db.WithContext(ctx).Create(&goal).Error; err != nil { return RewardGoalResult{}, err } currentAmount, err := s.currentSavedMoney(ctx, uid, now) if err != nil { return RewardGoalResult{}, err } return toRewardGoalResult(goal, uid, currentAmount), nil } // UpdateRewardGoal 更新梦想目标。 func (s *Service) UpdateRewardGoal(ctx context.Context, uid, id int, req UpdateRewardGoalRequest, now time.Time) (RewardGoalResult, error) { var goal quitmodel.RewardGoal if err := s.db.WithContext(ctx).Where("uid = ? AND id = ?", uid, id).First(&goal).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return RewardGoalResult{}, ErrRewardGoalNotFound } return RewardGoalResult{}, err } if req.Title != nil { goal.Title = strings.TrimSpace(*req.Title) } if req.TargetAmountCent != nil { goal.TargetAmountCent = *req.TargetAmountCent } if req.CoverImage != nil { goal.CoverImage = strings.TrimSpace(*req.CoverImage) } if req.Status != nil { goal.Status = strings.TrimSpace(strings.ToLower(*req.Status)) if goal.Status == quitmodel.RewardGoalStatusCompleted { nowCopy := now goal.CompletedAt = &nowCopy } } if err := s.db.WithContext(ctx).Save(&goal).Error; err != nil { return RewardGoalResult{}, err } currentAmount, err := s.currentSavedMoney(ctx, uid, now) if err != nil { return RewardGoalResult{}, err } return toRewardGoalResult(goal, uid, currentAmount), nil } // PosterData 返回海报预览数据。 func (s *Service) PosterData(ctx context.Context, uid int, nickname string, templateCode string, now time.Time) (PosterDataResult, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return PosterDataResult{}, ErrProfileNotFound } return PosterDataResult{}, err } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return PosterDataResult{}, err } if strings.TrimSpace(templateCode) == "" { templateCode = "vibrant_1" } return PosterDataResult{ Nickname: nickname, StreakDays: summary.CurrentStreakDays, SavedMoneyCent: summary.SavedMoneyCent, AvoidedCigs: summary.AvoidedCigs, HealthRecoveryPercent: summary.HealthRecoveryPercent, TemplateCode: templateCode, ShareTitle: fmt.Sprintf("我已经坚持戒烟 %d 天", summary.CurrentStreakDays), }, nil } // GeneratePoster 返回海报生成结果。 func (s *Service) GeneratePoster(ctx context.Context, uid int, nickname string, req PosterGenerateRequest, now time.Time) (PosterGenerateResult, error) { data, err := s.PosterData(ctx, uid, nickname, req.TemplateCode, now) if err != nil { return PosterGenerateResult{}, err } showFields := normalizeShowFields(req.ShowFields) if len(showFields) == 0 { showFields = []string{"streak_days", "saved_money_cent", "health_recovery_percent"} } return PosterGenerateResult{ TemplateCode: data.TemplateCode, ImageURL: fmt.Sprintf( "https://static.nepiedg.top/quit-checkin/posters/%d/%s/%d.png", uid, data.TemplateCode, now.Unix(), ), ShareTitle: data.ShareTitle, ShowFields: showFields, }, nil } func (s *Service) loadProfile(ctx context.Context, uid int) (*quitmodel.Profile, error) { var profile quitmodel.Profile if err := s.db.WithContext(ctx).Where("uid = ?", uid).First(&profile).Error; err != nil { return nil, err } return &profile, nil } func (s *Service) getOrBuildDailyStatus(ctx context.Context, uid int, date, now time.Time) (quitmodel.DailyStatus, error) { var status quitmodel.DailyStatus err := s.db.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error if err == nil { return status, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { return quitmodel.DailyStatus{}, err } return quitmodel.DailyStatus{ Date: date, Status: quitmodel.DailyStatusPending, }, nil } func (s *Service) firstActiveGoal(ctx context.Context, uid int, currentAmount int) (*RewardGoalResult, error) { var goal quitmodel.RewardGoal if err := s.db.WithContext(ctx).Where("uid = ? AND status = ?", uid, quitmodel.RewardGoalStatusActive).Order("created_at asc").First(&goal).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, err } result := toRewardGoalResult(goal, uid, currentAmount) return &result, nil } func (s *Service) currentSavedMoney(ctx context.Context, uid int, now time.Time) (int, error) { profile, err := s.loadProfile(ctx, uid) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, nil } return 0, err } summary, _, _, _, err := s.computeSummary(ctx, uid, *profile, now) if err != nil { return 0, err } return summary.SavedMoneyCent, nil } func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel.Profile, now time.Time) (SummaryResult, *string, *string, int, error) { today := normalizeDate(now) var statuses []quitmodel.DailyStatus if err := s.db.WithContext(ctx). Where("uid = ? AND date >= ? AND date <= ?", uid, profile.QuitStartDate, today). Order("date asc"). Find(&statuses).Error; err != nil { return SummaryResult{}, nil, nil, 0, err } statusMap := make(map[string]quitmodel.DailyStatus, len(statuses)) var lastCheckin *string var lastRelapse *string totalRelapseNum := 0 for _, item := range statuses { key := formatDate(item.Date) statusMap[key] = item if item.Status == quitmodel.DailyStatusCheckedIn { v := key lastCheckin = &v } if item.Status == quitmodel.DailyStatusRelapsed && item.RelapsedAt != nil { v := item.RelapsedAt.Format(time.RFC3339) lastRelapse = &v totalRelapseNum += item.RelapseNum } } currentStreak := 0 maxStreak := 0 runningStreak := 0 for cursor := normalizeDate(profile.QuitStartDate); !cursor.After(today); cursor = cursor.AddDate(0, 0, 1) { key := formatDate(cursor) item, ok := statusMap[key] status := quitmodel.DailyStatusMissed if ok { status = item.Status } else if isSameDate(cursor, today) { status = quitmodel.DailyStatusPending } if status == quitmodel.DailyStatusCheckedIn { runningStreak++ if runningStreak > maxStreak { maxStreak = runningStreak } } else { runningStreak = 0 } } todayKey := formatDate(today) if item, ok := statusMap[todayKey]; ok && item.Status == quitmodel.DailyStatusRelapsed { currentStreak = 0 } else { cursor := today if _, ok := statusMap[todayKey]; !ok { cursor = today.AddDate(0, 0, -1) } for !cursor.Before(normalizeDate(profile.QuitStartDate)) { item, ok := statusMap[formatDate(cursor)] if !ok || item.Status != quitmodel.DailyStatusCheckedIn { break } currentStreak++ cursor = cursor.AddDate(0, 0, -1) } } milestone := nextMilestone(currentStreak) daysToNext := 0 if milestone > currentStreak { daysToNext = milestone - currentStreak } elapsedDays := daysBetween(normalizeDate(profile.QuitStartDate), today) if currentStreak > elapsedDays { elapsedDays = currentStreak } theoreticalCigs := elapsedDays * profile.BaselineCigsPerDay avoidedCigs := theoreticalCigs - totalRelapseNum if avoidedCigs < 0 { avoidedCigs = 0 } savedMoney := 0 if profile.PackPriceCent > 0 { savedMoney = (avoidedCigs * profile.PackPriceCent) / 20 } healthPercent := minInt(currentStreak*5, 100) unlockedCount := 0 for _, day := range milestoneDays { if maxStreak >= day { unlockedCount++ } } return SummaryResult{ CurrentStreakDays: currentStreak, MaxStreakDays: maxStreak, MilestoneDays: milestone, DaysToNextMilestone: daysToNext, SavedMoneyCent: savedMoney, AvoidedCigs: avoidedCigs, AvoidedCigsMode: "exact", HealthRecoveryPercent: healthPercent, }, lastCheckin, lastRelapse, unlockedCount, nil } func toDailyStatusResult(status quitmodel.DailyStatus) DailyStatusResult { result := DailyStatusResult{ Date: formatDate(status.Date), Status: status.Status, } if result.Date == "0001-01-01" { result.Date = formatDate(normalizeDate(time.Now())) } if status.CheckInAt != nil { v := status.CheckInAt.Format(time.RFC3339) result.CheckinAt = &v } if status.RelapsedAt != nil { v := status.RelapsedAt.Format(time.RFC3339) result.RelapsedAt = &v num := status.RelapseNum result.RelapseNum = &num } if strings.TrimSpace(status.Note) != "" { v := status.Note result.Note = &v } return result } func toRewardGoalResult(goal quitmodel.RewardGoal, uid int, currentAmount int) RewardGoalResult { progress := 0 if goal.TargetAmountCent > 0 { progress = minInt((currentAmount*100)/goal.TargetAmountCent, 100) } var completedAt *string if goal.CompletedAt != nil { v := goal.CompletedAt.Format(time.RFC3339) completedAt = &v } current := currentAmount if goal.TargetAmountCent > 0 && current > goal.TargetAmountCent { current = goal.TargetAmountCent } return RewardGoalResult{ ID: goal.ID, UserID: uid, Title: goal.Title, TargetAmountCent: goal.TargetAmountCent, CurrentAmountCent: current, ProgressPercent: progress, CoverImage: goal.CoverImage, Status: goal.Status, CompletedAt: completedAt, CreatedAt: goal.CreatedAt.Format(time.RFC3339), } } func nextMilestone(current int) int { for _, item := range milestoneDays { if item > current { return item } } return milestoneDays[len(milestoneDays)-1] } func normalizeDate(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local) } func formatDate(t time.Time) string { return normalizeDate(t).Format(dateLayout) } func daysBetween(start, end time.Time) int { start = normalizeDate(start) end = normalizeDate(end) if end.Before(start) { return 0 } return int(end.Sub(start).Hours() / 24) } func daysBetweenInclusive(start, end time.Time) int { return daysBetween(start, end) + 1 } func isSameDate(a, b time.Time) bool { return normalizeDate(a).Equal(normalizeDate(b)) } func minInt(a, b int) int { if a < b { return a } return b } // ParseNotifyTime 校验提醒时间是否符合 HH:MM 格式。 func ParseNotifyTime(v string) bool { if strings.TrimSpace(v) == "" { return true } _, err := time.ParseInLocation(timeOnlyLayout, v, time.Local) return err == nil } // SortRelapses 按日期和复吸时间倒序整理复吸列表。 func SortRelapses(items []RelapseEventResult) { sort.Slice(items, func(i, j int) bool { if items[i].Date == items[j].Date { return items[i].RelapseAt > items[j].RelapseAt } return items[i].Date > items[j].Date }) } // ListDreamPresets 返回启用的预设梦想目标列表。 func (s *Service) ListDreamPresets(ctx context.Context) ([]quitmodel.DreamPreset, error) { var presets []quitmodel.DreamPreset if err := s.db.WithContext(ctx). Where("is_active = ?", true). Order("sort_order ASC, id ASC"). Find(&presets).Error; err != nil { return nil, fmt.Errorf("list dream presets: %w", err) } return presets, nil } func normalizeShowFields(items []string) []string { allowed := map[string]struct{}{ "streak_days": {}, "saved_money_cent": {}, "avoided_cigs": {}, "health_recovery_percent": {}, } result := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { key := strings.TrimSpace(strings.ToLower(item)) if key == "" { continue } if _, ok := allowed[key]; !ok { continue } if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} result = append(result, key) } return result }