fd097729d7
- 成就系统、连续打卡天数计算、管理后台成就 CRUD - 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口 - 管理后台梦想图标 CRUD;戒烟打卡 summary 修正 - 忽略根目录编译产物 /api Made-with: Cursor
267 lines
6.6 KiB
Go
267 lines
6.6 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
smokemodel "wx_service/internal/smoke/model"
|
|
)
|
|
|
|
var (
|
|
ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM")
|
|
)
|
|
|
|
const (
|
|
SmokeModeQuit = "quit"
|
|
SmokeModeRecord = "record"
|
|
)
|
|
|
|
type SmokeProfileService struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewSmokeProfileService(db *gorm.DB) *SmokeProfileService {
|
|
return &SmokeProfileService{db: db}
|
|
}
|
|
|
|
type SmokeProfileView struct {
|
|
Exists bool `json:"exists"`
|
|
Profile *smokemodel.SmokeUserProfile `json:"profile,omitempty"`
|
|
|
|
IsCompleted bool `json:"is_completed"`
|
|
AwakeMinutes int `json:"awake_minutes"`
|
|
BaselineIntervalMinute int `json:"baseline_interval_minutes"`
|
|
}
|
|
|
|
func (s *SmokeProfileService) GetView(ctx context.Context, uid int) (SmokeProfileView, error) {
|
|
profile, err := s.Get(ctx, uid)
|
|
if err != nil {
|
|
return SmokeProfileView{}, err
|
|
}
|
|
if profile == nil {
|
|
return SmokeProfileView{
|
|
Exists: false,
|
|
Profile: nil,
|
|
IsCompleted: false,
|
|
AwakeMinutes: defaultAwakeMinutes,
|
|
BaselineIntervalMinute: 0,
|
|
}, nil
|
|
}
|
|
profile.Mode = normalizedSmokeMode(profile.Mode)
|
|
|
|
awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime)
|
|
if err != nil {
|
|
return SmokeProfileView{}, err
|
|
}
|
|
|
|
isCompleted := isSmokeProfileCompleted(*profile)
|
|
interval := baselineIntervalMinutes(awakeMinutes, profile.BaselineCigsPerDay)
|
|
|
|
return SmokeProfileView{
|
|
Exists: true,
|
|
Profile: profile,
|
|
IsCompleted: isCompleted,
|
|
AwakeMinutes: awakeMinutes,
|
|
BaselineIntervalMinute: interval,
|
|
}, nil
|
|
}
|
|
|
|
func (s *SmokeProfileService) Get(ctx context.Context, uid int) (*smokemodel.SmokeUserProfile, error) {
|
|
var profile smokemodel.SmokeUserProfile
|
|
err := s.db.WithContext(ctx).
|
|
Where("uid = ? AND deleted_at IS NULL", uid).
|
|
First(&profile).Error
|
|
if err == nil {
|
|
return &profile, nil
|
|
}
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("load smoke profile: %w", err)
|
|
}
|
|
|
|
type UpsertSmokeProfileRequest struct {
|
|
BaselineCigsPerDay *int
|
|
SmokingYears *float64
|
|
PackPriceCent *int
|
|
Mode *string
|
|
|
|
SmokeMotivations *[]string
|
|
QuitMotivations *[]string
|
|
|
|
WakeUpTime *string
|
|
SleepTime *string
|
|
|
|
QuitDateProvided bool
|
|
QuitDate *time.Time
|
|
|
|
AchievementThemeIDProvided bool
|
|
AchievementThemeID *uint
|
|
}
|
|
|
|
func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) {
|
|
var profile smokemodel.SmokeUserProfile
|
|
tx := s.db.WithContext(ctx)
|
|
err := tx.Where("uid = ? AND deleted_at IS NULL", uid).First(&profile).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return SmokeProfileView{}, fmt.Errorf("load smoke profile: %w", err)
|
|
}
|
|
isNew := errors.Is(err, gorm.ErrRecordNotFound)
|
|
if isNew {
|
|
profile = smokemodel.SmokeUserProfile{UID: uid}
|
|
}
|
|
|
|
applyInt := func(dst *int, v *int) {
|
|
if v != nil {
|
|
*dst = *v
|
|
}
|
|
}
|
|
applyFloat := func(dst *float64, v *float64) {
|
|
if v != nil {
|
|
*dst = *v
|
|
}
|
|
}
|
|
applyMode := func(dst *string, v *string) {
|
|
if v == nil {
|
|
return
|
|
}
|
|
*dst = normalizedSmokeMode(*v)
|
|
}
|
|
applyTimeStr := func(dst *string, v *string) error {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
value := strings.TrimSpace(*v)
|
|
if value == "" {
|
|
*dst = ""
|
|
return nil
|
|
}
|
|
if _, err := parseHHMMToMinutes(value); err != nil {
|
|
return ErrSmokeProfileInvalidTime
|
|
}
|
|
*dst = value
|
|
return nil
|
|
}
|
|
|
|
applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay)
|
|
applyFloat(&profile.SmokingYears, req.SmokingYears)
|
|
applyInt(&profile.PackPriceCent, req.PackPriceCent)
|
|
applyMode(&profile.Mode, req.Mode)
|
|
|
|
if req.SmokeMotivations != nil {
|
|
profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations)
|
|
}
|
|
if req.QuitMotivations != nil {
|
|
profile.QuitMotivations = smokemodel.StringSlice(*req.QuitMotivations)
|
|
}
|
|
|
|
if err := applyTimeStr(&profile.WakeUpTime, req.WakeUpTime); err != nil {
|
|
return SmokeProfileView{}, err
|
|
}
|
|
if err := applyTimeStr(&profile.SleepTime, req.SleepTime); err != nil {
|
|
return SmokeProfileView{}, err
|
|
}
|
|
if req.QuitDateProvided {
|
|
profile.QuitDate = req.QuitDate
|
|
}
|
|
if req.AchievementThemeIDProvided {
|
|
profile.AchievementThemeID = req.AchievementThemeID
|
|
}
|
|
|
|
now := time.Now()
|
|
profile.Mode = normalizedSmokeMode(profile.Mode)
|
|
if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) {
|
|
profile.OnboardingCompletedAt = &now
|
|
}
|
|
|
|
if isNew {
|
|
if err := tx.Create(&profile).Error; err != nil {
|
|
return SmokeProfileView{}, fmt.Errorf("create smoke profile: %w", err)
|
|
}
|
|
} else {
|
|
if err := tx.Save(&profile).Error; err != nil {
|
|
return SmokeProfileView{}, fmt.Errorf("save smoke profile: %w", err)
|
|
}
|
|
}
|
|
|
|
return s.GetView(ctx, uid)
|
|
}
|
|
|
|
func isSmokeProfileCompleted(p smokemodel.SmokeUserProfile) bool {
|
|
return p.BaselineCigsPerDay > 0 &&
|
|
p.PackPriceCent > 0 &&
|
|
len(p.QuitMotivations) > 0 &&
|
|
strings.TrimSpace(p.WakeUpTime) != "" &&
|
|
strings.TrimSpace(p.SleepTime) != ""
|
|
}
|
|
|
|
const defaultAwakeMinutes = 16 * 60
|
|
|
|
func awakeMinutesWithFallback(wakeUp, sleep string) (int, error) {
|
|
wakeUp = strings.TrimSpace(wakeUp)
|
|
sleep = strings.TrimSpace(sleep)
|
|
if wakeUp == "" || sleep == "" {
|
|
return defaultAwakeMinutes, nil
|
|
}
|
|
wakeMin, err := parseHHMMToMinutes(wakeUp)
|
|
if err != nil {
|
|
return 0, ErrSmokeProfileInvalidTime
|
|
}
|
|
sleepMin, err := parseHHMMToMinutes(sleep)
|
|
if err != nil {
|
|
return 0, ErrSmokeProfileInvalidTime
|
|
}
|
|
if sleepMin == wakeMin {
|
|
return 24 * 60, nil
|
|
}
|
|
if sleepMin > wakeMin {
|
|
return sleepMin - wakeMin, nil
|
|
}
|
|
return (24*60 - wakeMin) + sleepMin, nil
|
|
}
|
|
|
|
func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int {
|
|
if awakeMinutes <= 0 || baselineCigsPerDay <= 0 {
|
|
return 0
|
|
}
|
|
interval := awakeMinutes / baselineCigsPerDay
|
|
if interval <= 0 {
|
|
return 1
|
|
}
|
|
return interval
|
|
}
|
|
|
|
func normalizedSmokeMode(mode string) string {
|
|
switch strings.TrimSpace(mode) {
|
|
case SmokeModeQuit:
|
|
return SmokeModeQuit
|
|
case SmokeModeRecord:
|
|
return SmokeModeRecord
|
|
default:
|
|
return SmokeModeRecord
|
|
}
|
|
}
|
|
|
|
func parseHHMMToMinutes(s string) (int, error) {
|
|
s = strings.TrimSpace(s)
|
|
if len(s) != 5 || s[2] != ':' {
|
|
return 0, ErrSmokeProfileInvalidTime
|
|
}
|
|
h1, h2 := s[0], s[1]
|
|
m1, m2 := s[3], s[4]
|
|
if h1 < '0' || h1 > '9' || h2 < '0' || h2 > '9' || m1 < '0' || m1 > '9' || m2 < '0' || m2 > '9' {
|
|
return 0, ErrSmokeProfileInvalidTime
|
|
}
|
|
hour := int(h1-'0')*10 + int(h2-'0')
|
|
min := int(m1-'0')*10 + int(m2-'0')
|
|
if hour < 0 || hour > 23 || min < 0 || min > 59 {
|
|
return 0, ErrSmokeProfileInvalidTime
|
|
}
|
|
return hour*60 + min, nil
|
|
}
|