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

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

1172 lines
34 KiB
Go

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
}