fd097729d7
- 成就系统、连续打卡天数计算、管理后台成就 CRUD - 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口 - 管理后台梦想图标 CRUD;戒烟打卡 summary 修正 - 忽略根目录编译产物 /api Made-with: Cursor
1172 lines
34 KiB
Go
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
|
|
}
|