Files
wx_service/internal/quitcheckin/service/service.go
T

1463 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
HPCurrent int `json:"hp_current"`
HPChangeToday int `json:"hp_change_today"`
}
// 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
shouldApplyHP := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return 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 e := tx.WithContext(ctx).Create(&status).Error; e != nil {
return e
}
shouldApplyHP = true
} else {
if status.Status == quitmodel.DailyStatusRelapsed {
return ErrAlreadyRelapsed
}
if status.Status != quitmodel.DailyStatusCheckedIn {
status.Status = quitmodel.DailyStatusCheckedIn
status.CheckInAt = &checkinAt
status.Note = strings.TrimSpace(req.Note)
if e := tx.WithContext(ctx).Save(&status).Error; e != nil {
return e
}
shouldApplyHP = true
}
}
if !shouldApplyHP {
return nil
}
hpBefore, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, now)
if err != nil {
return err
}
// 打卡回血:前期更快,后期更慢
delta := 2
switch {
case hpBefore < 40:
delta = 4
case hpBefore < 70:
delta = 3
case hpBefore < 90:
delta = 2
default:
delta = 1
}
return s.applyHPDeltaTx(ctx, tx, profile, delta, "checkin", "daily_status", uintPtr(status.ID), now)
})
if 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 {
wasRelapsed := false
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 {
wasRelapsed = status.Status == quitmodel.DailyStatusRelapsed
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: !wasRelapsed,
}
if e := tx.Create(&event).Error; e != nil {
return e
}
hpBefore, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, relapsedAt)
if err != nil {
return err
}
_ = hpBefore
// 复吸:每支烟扣 M;当日首次复吸额外惩罚。
perCig := 4
delta := -perCig * maxInt(1, req.RelapseNum)
reason := "smoke"
if !wasRelapsed {
delta -= 10
reason = "relapse"
}
return s.applyHPDeltaTx(ctx, tx, profile, delta, reason, "relapse_event", uintPtr(event.ID), relapsedAt)
})
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
}
// RecordSmokeSlip 用于 quit 模式下的“抽烟记录”同步到 quitcheckin
// - 当天标记为 relapsed
// - 写入 relapse_event(同一天第二次 slip 不再 affect_streak
// - 扣减 HP(每支烟扣 M;首次 slip 额外惩罚)
func (s *Service) RecordSmokeSlip(ctx context.Context, uid int, slipAt time.Time, slipNum int, note string) error {
if slipNum <= 0 {
return nil
}
profile, err := s.loadProfile(ctx, uid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
date := normalizeDate(slipAt)
var status quitmodel.DailyStatus
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.WithContext(ctx).Where("uid = ? AND date = ?", uid, date).First(&status).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
wasRelapsed := false
if errors.Is(err, gorm.ErrRecordNotFound) {
status = quitmodel.DailyStatus{
UID: uid,
Date: date,
Status: quitmodel.DailyStatusRelapsed,
RelapsedAt: &slipAt,
RelapseNum: slipNum,
Note: strings.TrimSpace(note),
CheckInAt: nil,
}
if e := tx.WithContext(ctx).Create(&status).Error; e != nil {
return e
}
} else {
wasRelapsed = status.Status == quitmodel.DailyStatusRelapsed
status.Status = quitmodel.DailyStatusRelapsed
status.CheckInAt = nil
status.RelapsedAt = &slipAt
// 多次 slip:累加当日 relapse_num,便于 avoided_cigs 统计更接近真实。
status.RelapseNum += slipNum
if strings.TrimSpace(note) != "" && strings.TrimSpace(status.Note) == "" {
status.Note = strings.TrimSpace(note)
}
if e := tx.WithContext(ctx).Save(&status).Error; e != nil {
return e
}
}
event := quitmodel.RelapseEvent{
UID: uid,
Date: date,
RelapseAt: slipAt,
RelapseNum: slipNum,
Reason: "smoke_log",
Note: strings.TrimSpace(note),
AffectStreak: !wasRelapsed,
}
if e := tx.WithContext(ctx).Create(&event).Error; e != nil {
return e
}
// 复吸:每支烟扣 M;当日首次复吸额外惩罚。
perCig := 4
delta := -perCig * maxInt(1, slipNum)
reason := "smoke"
if !wasRelapsed {
delta -= 10
reason = "relapse"
}
if _, err := s.ensureHPInitializedBasicTx(ctx, tx, profile, slipAt); err != nil {
return err
}
return s.applyHPDeltaTx(ctx, tx, profile, delta, reason, "smoke_log", nil, slipAt)
})
}
// 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) ensureHPInitialized(ctx context.Context, profile *quitmodel.Profile, currentStreak int, now time.Time) (int, error) {
if profile.HpCurrent != nil {
return clampHP(*profile.HpCurrent), nil
}
// 迁移/初始化:只依赖 quitcheckin profile 的 baseline + 当前 streak,避免跨模块耦合。
base := 50
if profile.BaselineCigsPerDay > 0 {
base = 60 - profile.BaselineCigsPerDay
}
base = clampInt(base, 20, 60)
initHP := clampHP(base + currentStreak*3)
profile.HpCurrent = &initHP
changeDate := normalizeDate(now)
row := quitmodel.HPChangeLog{
UID: profile.UID,
ChangeDate: changeDate,
ChangeAt: now,
Delta: 0,
HPBefore: initHP,
HPAfter: initHP,
Reason: "migrate_init",
SourceType: "profile",
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 条件更新,避免并发覆盖(nil -> initHP 只做一次)。
if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}).
Where("id = ? AND uid = ? AND hp_current IS NULL", profile.ID, profile.UID).
Updates(map[string]interface{}{"hp_current": initHP}).Error; e != nil {
return e
}
return tx.WithContext(ctx).Create(&row).Error
}); err != nil {
return 0, err
}
return initHP, nil
}
func (s *Service) sumHPChangeByDate(ctx context.Context, uid int, date time.Time) (int, error) {
date = normalizeDate(date)
var sum int64
if err := s.db.WithContext(ctx).
Model(&quitmodel.HPChangeLog{}).
Where("uid = ? AND change_date = ?", uid, date).
Select("COALESCE(SUM(delta), 0)").
Scan(&sum).Error; err != nil {
return 0, err
}
return int(sum), nil
}
func (s *Service) ensureHPInitializedBasicTx(ctx context.Context, tx *gorm.DB, profile *quitmodel.Profile, now time.Time) (int, error) {
if profile.HpCurrent != nil {
return clampHP(*profile.HpCurrent), nil
}
// 基础初始化:不引入 streak(事件触发时先给个合理起点;更准确的迁移会在 computeSummary 中做)。
base := 50
if profile.BaselineCigsPerDay > 0 {
base = 60 - profile.BaselineCigsPerDay
}
base = clampInt(base, 20, 60)
initHP := clampHP(base)
profile.HpCurrent = &initHP
if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}).
Where("id = ? AND uid = ? AND hp_current IS NULL", profile.ID, profile.UID).
Updates(map[string]interface{}{"hp_current": initHP}).Error; e != nil {
return 0, e
}
row := quitmodel.HPChangeLog{
UID: profile.UID,
ChangeDate: normalizeDate(now),
ChangeAt: now,
Delta: 0,
HPBefore: initHP,
HPAfter: initHP,
Reason: "migrate_init",
SourceType: "profile",
}
if e := tx.WithContext(ctx).Create(&row).Error; e != nil {
return 0, e
}
return initHP, nil
}
func (s *Service) applyHPDeltaTx(ctx context.Context, tx *gorm.DB, profile *quitmodel.Profile, delta int, reason string, sourceType string, sourceID *uint, changeAt time.Time) error {
before := 0
if profile.HpCurrent != nil {
before = clampHP(*profile.HpCurrent)
}
after := clampHP(before + delta)
if e := tx.WithContext(ctx).Model(&quitmodel.Profile{}).
Where("id = ? AND uid = ?", profile.ID, profile.UID).
Updates(map[string]interface{}{"hp_current": after}).Error; e != nil {
return e
}
profile.HpCurrent = &after
row := quitmodel.HPChangeLog{
UID: profile.UID,
ChangeDate: normalizeDate(changeAt),
ChangeAt: changeAt,
Delta: after - before,
HPBefore: before,
HPAfter: after,
Reason: strings.TrimSpace(reason),
SourceType: strings.TrimSpace(sourceType),
SourceID: sourceID,
}
return tx.WithContext(ctx).Create(&row).Error
}
func clampHP(v int) int {
return clampInt(v, 0, 100)
}
func clampInt(v, minV, maxV int) int {
if v < minV {
return minV
}
if v > maxV {
return maxV
}
return v
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func uintPtr(v uint) *uint {
return &v
}
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++
}
}
hpCurrent, err := s.ensureHPInitialized(ctx, profile, currentStreak, now)
if err != nil {
return SummaryResult{}, nil, nil, 0, err
}
hpChangeToday, err := s.sumHPChangeByDate(ctx, uid, today)
if err != nil {
return SummaryResult{}, nil, nil, 0, err
}
return SummaryResult{
CurrentStreakDays: currentStreak,
MaxStreakDays: maxStreak,
MilestoneDays: milestone,
DaysToNextMilestone: daysToNext,
SavedMoneyCent: savedMoney,
AvoidedCigs: avoidedCigs,
AvoidedCigsMode: "exact",
HealthRecoveryPercent: healthPercent,
HPCurrent: hpCurrent,
HPChangeToday: hpChangeToday,
}, 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
}