1463 lines
41 KiB
Go
1463 lines
41 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"`
|
||
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
|
||
}
|