From fd097729d76cbc1908f0a333c9fb1b2bdfac2ceb Mon Sep 17 00:00:00 2001 From: nepiedg Date: Sat, 4 Apr 2026 14:55:50 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=88=92=E7=83=9F=E6=88=90?= =?UTF-8?q?=E5=B0=B1=E3=80=81=E6=A2=A6=E6=83=B3=E5=9B=BE=E6=A0=87=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E3=80=81=E6=89=93=E5=8D=A1=E7=BB=9F=E8=AE=A1=E4=B8=8E?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E6=B3=A8=E5=85=A5=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 成就系统、连续打卡天数计算、管理后台成就 CRUD - 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口 - 管理后台梦想图标 CRUD;戒烟打卡 summary 修正 - 忽略根目录编译产物 /api Made-with: Cursor --- .gitignore | 1 + cmd/api/main.go | 12 +- internal/achievement/model.go | 43 ++++ internal/achievement/service.go | 187 ++++++++++++++++++ internal/admin/handler/achievement_handler.go | 162 +++++++++++++++ .../admin/handler/dream_preset_handler.go | 83 ++++++++ internal/admin/service/achievement_service.go | 127 ++++++++++++ .../admin/service/dream_preset_service.go | 63 ++++++ internal/quitcheckin/handler/handler.go | 10 + internal/quitcheckin/model/dream_preset.go | 23 +++ internal/quitcheckin/service/service.go | 15 ++ internal/routes/admin_routes.go | 15 ++ internal/routes/quitcheckin_routes.go | 1 + internal/routes/smoke_routes.go | 4 + .../handler/smoke_achievement_handler.go | 69 +++++++ internal/smoke/handler/smoke_handler.go | 8 + .../smoke/handler/smoke_profile_handler.go | 26 ++- internal/smoke/model/smoke_profile.go | 2 + .../smoke/service/smoke_profile_service.go | 6 + internal/smoke/service/smoke_stats_service.go | 4 + 20 files changed, 849 insertions(+), 12 deletions(-) create mode 100644 internal/achievement/model.go create mode 100644 internal/achievement/service.go create mode 100644 internal/admin/handler/achievement_handler.go create mode 100644 internal/admin/handler/dream_preset_handler.go create mode 100644 internal/admin/service/achievement_service.go create mode 100644 internal/admin/service/dream_preset_service.go create mode 100644 internal/quitcheckin/model/dream_preset.go create mode 100644 internal/smoke/handler/smoke_achievement_handler.go diff --git a/.gitignore b/.gitignore index 047882e..bee68ec 100755 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ go.work # Local build binary wx_service wx_service_api +/api diff --git a/cmd/api/main.go b/cmd/api/main.go index c8abf45..4896bd6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -30,6 +30,7 @@ import ( membershipservice "wx_service/internal/membership/service" "wx_service/internal/model" "wx_service/internal/observability" + "wx_service/internal/achievement" quitcheckinhandler "wx_service/internal/quitcheckin/handler" quitcheckinmodel "wx_service/internal/quitcheckin/model" quitcheckinservice "wx_service/internal/quitcheckin/service" @@ -88,6 +89,9 @@ func main() { &quitcheckinmodel.DailyStatus{}, &quitcheckinmodel.RelapseEvent{}, &quitcheckinmodel.RewardGoal{}, + &quitcheckinmodel.DreamPreset{}, + &achievement.Theme{}, + &achievement.Level{}, ); err != nil { log.Fatalf("auto migrate failed: %v", err) } @@ -117,9 +121,13 @@ func main() { smokeAINextService := smokeservice.NewSmokeAINextSmokeService(database.DB, config.AppConfig.AI) smokeShareService := smokeservice.NewSmokeShareService(database.DB) smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI) - smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService) - quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService) + achievementService := achievement.NewService(database.DB) + if err := achievementService.SeedDefaults(context.Background()); err != nil { + log.Printf("seed achievement defaults: %v", err) + } quitCheckinService := quitcheckinservice.NewService(database.DB) + smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService, achievementService, quitCheckinService) + quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService) quitCheckinHandler := quitcheckinhandler.NewHandler(quitCheckinService) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) diff --git a/internal/achievement/model.go b/internal/achievement/model.go new file mode 100644 index 0000000..36ff116 --- /dev/null +++ b/internal/achievement/model.go @@ -0,0 +1,43 @@ +package achievement + +import ( + "time" + + "gorm.io/gorm" +) + +type Theme struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Name string `gorm:"size:50;not null;comment:主题名称" json:"name"` + Key string `gorm:"size:50;uniqueIndex;not null;comment:主题标识" json:"key"` + Icon string `gorm:"size:255;comment:主题图标" json:"icon"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` + IsActive bool `gorm:"default:true;comment:是否启用" json:"is_active"` + + Levels []Level `gorm:"foreignKey:ThemeID" json:"levels,omitempty"` +} + +func (Theme) TableName() string { + return "fa_achievement_theme" +} + +type Level struct { + ID uint `gorm:"primaryKey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + ThemeID uint `gorm:"index;not null;comment:主题ID" json:"theme_id"` + Name string `gorm:"size:50;not null;comment:等级名称" json:"name"` + Icon string `gorm:"size:255;comment:等级图标" json:"icon"` + RequiredDays int `gorm:"not null;default:0;comment:所需打卡天数" json:"required_days"` + SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"` +} + +func (Level) TableName() string { + return "fa_achievement_level" +} diff --git a/internal/achievement/service.go b/internal/achievement/service.go new file mode 100644 index 0000000..7f1b152 --- /dev/null +++ b/internal/achievement/service.go @@ -0,0 +1,187 @@ +package achievement + +import ( + "context" + "errors" + "fmt" + + "gorm.io/gorm" +) + +var ErrThemeNotFound = errors.New("achievement theme not found") + +type Service struct { + db *gorm.DB +} + +func NewService(db *gorm.DB) *Service { + return &Service{db: db} +} + +type UserAchievement struct { + ThemeID uint `json:"theme_id"` + ThemeName string `json:"theme_name"` + ThemeKey string `json:"theme_key"` + ThemeIcon string `json:"theme_icon"` + Days int `json:"days"` + Current *Level `json:"current"` + Next *Level `json:"next,omitempty"` + Progress float64 `json:"progress"` +} + +func (s *Service) ListActiveThemes(ctx context.Context) ([]Theme, error) { + var themes []Theme + err := s.db.WithContext(ctx). + Where("is_active = ?", true). + Preload("Levels", func(db *gorm.DB) *gorm.DB { + return db.Order("required_days ASC, sort_order ASC") + }). + Order("sort_order ASC, id ASC"). + Find(&themes).Error + if err != nil { + return nil, fmt.Errorf("list active themes: %w", err) + } + return themes, nil +} + +func (s *Service) GetUserAchievement(ctx context.Context, themeID uint, days int) (*UserAchievement, error) { + var theme Theme + err := s.db.WithContext(ctx). + Preload("Levels", func(db *gorm.DB) *gorm.DB { + return db.Order("required_days ASC, sort_order ASC") + }). + First(&theme, themeID).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrThemeNotFound + } + return nil, fmt.Errorf("get theme: %w", err) + } + + if days < 0 { + days = 0 + } + + result := &UserAchievement{ + ThemeID: theme.ID, + ThemeName: theme.Name, + ThemeKey: theme.Key, + ThemeIcon: theme.Icon, + Days: days, + } + + for i, level := range theme.Levels { + if days >= level.RequiredDays { + lvl := level + result.Current = &lvl + if i+1 < len(theme.Levels) { + next := theme.Levels[i+1] + result.Next = &next + } + } + } + + if result.Current != nil && result.Next != nil { + rangeTotal := result.Next.RequiredDays - result.Current.RequiredDays + rangeDone := days - result.Current.RequiredDays + if rangeTotal > 0 { + result.Progress = float64(rangeDone) / float64(rangeTotal) + } + } else if result.Current != nil && result.Next == nil { + result.Progress = 1.0 + } + + return result, nil +} + +func (s *Service) SeedDefaults(ctx context.Context) error { + var count int64 + if err := s.db.WithContext(ctx).Model(&Theme{}).Count(&count).Error; err != nil { + return fmt.Errorf("count themes: %w", err) + } + if count > 0 { + return nil + } + + themes := []struct { + Name string + Key string + Icon string + Levels []struct { + Name string + RequiredDays int + } + }{ + { + Name: "修仙", Key: "xiuxian", Icon: "⚔️", + Levels: []struct { + Name string + RequiredDays int + }{ + {"炼体", 0}, {"练气", 3}, {"筑基", 7}, {"金丹", 30}, {"元婴", 90}, {"化神", 365}, + }, + }, + { + Name: "军队", Key: "army", Icon: "🎖️", + Levels: []struct { + Name string + RequiredDays int + }{ + {"新兵", 0}, {"排长", 3}, {"连长", 7}, {"营长", 30}, {"师长", 90}, {"大将", 365}, + }, + }, + { + Name: "魔法", Key: "magic", Icon: "🔮", + Levels: []struct { + Name string + RequiredDays int + }{ + {"学徒", 0}, {"术士", 3}, {"大术士", 7}, {"元素师", 30}, {"大魔导师", 90}, {"圣导师", 365}, + }, + }, + { + Name: "武侠", Key: "wuxia", Icon: "🗡️", + Levels: []struct { + Name string + RequiredDays int + }{ + {"徒弟", 0}, {"掌门弟子", 3}, {"门派高手", 7}, {"大侠", 30}, {"剑仙", 90}, {"武圣", 365}, + }, + }, + { + Name: "电竞", Key: "esports", Icon: "🏆", + Levels: []struct { + Name string + RequiredDays int + }{ + {"青铜", 0}, {"白银", 3}, {"黄金", 7}, {"铂金", 30}, {"钻石", 90}, {"王者", 365}, + }, + }, + } + + for i, t := range themes { + theme := Theme{ + Name: t.Name, + Key: t.Key, + Icon: t.Icon, + SortOrder: i, + IsActive: true, + } + if err := s.db.WithContext(ctx).Create(&theme).Error; err != nil { + return fmt.Errorf("seed theme %s: %w", t.Key, err) + } + for j, l := range t.Levels { + level := Level{ + ThemeID: theme.ID, + Name: l.Name, + RequiredDays: l.RequiredDays, + SortOrder: j, + } + if err := s.db.WithContext(ctx).Create(&level).Error; err != nil { + return fmt.Errorf("seed level %s/%s: %w", t.Key, l.Name, err) + } + } + } + + return nil +} diff --git a/internal/admin/handler/achievement_handler.go b/internal/admin/handler/achievement_handler.go new file mode 100644 index 0000000..3470e59 --- /dev/null +++ b/internal/admin/handler/achievement_handler.go @@ -0,0 +1,162 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "wx_service/internal/model" +) + +type achievementThemeRequest struct { + Name string `json:"name"` + Key string `json:"key"` + Icon string `json:"icon"` + SortOrder int `json:"sort_order"` + IsActive *bool `json:"is_active"` +} + +type achievementLevelRequest struct { + ThemeID uint `json:"theme_id"` + Name string `json:"name"` + Icon string `json:"icon"` + RequiredDays int `json:"required_days"` + SortOrder int `json:"sort_order"` +} + +func (h *Handler) ListAchievementThemes(c *gin.Context) { + themes, err := h.svc.ListAchievementThemes(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取主题列表失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"themes": themes})) +} + +func (h *Handler) GetAchievementTheme(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + theme, err := h.svc.GetAchievementTheme(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "主题不存在")) + return + } + c.JSON(http.StatusOK, model.Success(theme)) +} + +func (h *Handler) CreateAchievementTheme(c *gin.Context) { + var req achievementThemeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + if req.Name == "" || req.Key == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "name 和 key 必填")) + return + } + theme, err := h.svc.CreateAchievementTheme(c.Request.Context(), req.Name, req.Key, req.Icon, req.SortOrder, req.IsActive) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建主题失败")) + return + } + c.JSON(http.StatusOK, model.Success(theme)) +} + +func (h *Handler) UpdateAchievementTheme(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req achievementThemeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + theme, err := h.svc.UpdateAchievementTheme(c.Request.Context(), id, req.Name, req.Key, req.Icon, req.SortOrder, req.IsActive) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "更新主题失败")) + return + } + c.JSON(http.StatusOK, model.Success(theme)) +} + +func (h *Handler) DeleteAchievementTheme(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteAchievementTheme(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "删除主题失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"deleted": true})) +} + +func (h *Handler) ListAchievementLevels(c *gin.Context) { + themeID, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid theme id")) + return + } + levels, err := h.svc.ListAchievementLevels(c.Request.Context(), themeID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取等级列表失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"levels": levels})) +} + +func (h *Handler) CreateAchievementLevel(c *gin.Context) { + var req achievementLevelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + if req.Name == "" || req.ThemeID == 0 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "name 和 theme_id 必填")) + return + } + level, err := h.svc.CreateAchievementLevel(c.Request.Context(), req.ThemeID, req.Name, req.Icon, req.RequiredDays, req.SortOrder) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建等级失败")) + return + } + c.JSON(http.StatusOK, model.Success(level)) +} + +func (h *Handler) UpdateAchievementLevel(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + var req achievementLevelRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + level, err := h.svc.UpdateAchievementLevel(c.Request.Context(), id, req.Name, req.Icon, req.RequiredDays, req.SortOrder) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "更新等级失败")) + return + } + c.JSON(http.StatusOK, model.Success(level)) +} + +func (h *Handler) DeleteAchievementLevel(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid id")) + return + } + if err := h.svc.DeleteAchievementLevel(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "删除等级失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"deleted": true})) +} diff --git a/internal/admin/handler/dream_preset_handler.go b/internal/admin/handler/dream_preset_handler.go new file mode 100644 index 0000000..11a1c1e --- /dev/null +++ b/internal/admin/handler/dream_preset_handler.go @@ -0,0 +1,83 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "wx_service/internal/model" +) + +type dreamPresetRequest struct { + Title string `json:"title"` + CoverImage string `json:"cover_image"` + SortOrder int `json:"sort_order"` + IsActive *bool `json:"is_active"` +} + +func (h *Handler) ListDreamPresets(c *gin.Context) { + presets, err := h.svc.ListDreamPresets(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取预设目标失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"items": presets})) +} + +func (h *Handler) CreateDreamPreset(c *gin.Context) { + var req dreamPresetRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "参数错误")) + return + } + if strings.TrimSpace(req.CoverImage) == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "图标不能为空")) + return + } + preset, err := h.svc.CreateDreamPreset(c.Request.Context(), req.Title, req.CoverImage, req.SortOrder, req.IsActive) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建失败")) + return + } + c.JSON(http.StatusOK, model.Success(preset)) +} + +type dreamPresetUpdateRequest struct { + Title *string `json:"title"` + CoverImage *string `json:"cover_image"` + SortOrder *int `json:"sort_order"` + IsActive *bool `json:"is_active"` +} + +func (h *Handler) UpdateDreamPreset(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误")) + return + } + var req dreamPresetUpdateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "参数错误")) + return + } + preset, err := h.svc.UpdateDreamPreset(c.Request.Context(), id, req.Title, req.CoverImage, req.SortOrder, req.IsActive) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "更新失败")) + return + } + c.JSON(http.StatusOK, model.Success(preset)) +} + +func (h *Handler) DeleteDreamPreset(c *gin.Context) { + id, err := parseUintID(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "id 参数错误")) + return + } + if err := h.svc.DeleteDreamPreset(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "删除失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"deleted": true})) +} diff --git a/internal/admin/service/achievement_service.go b/internal/admin/service/achievement_service.go new file mode 100644 index 0000000..b8af7d4 --- /dev/null +++ b/internal/admin/service/achievement_service.go @@ -0,0 +1,127 @@ +package service + +import ( + "context" + "fmt" + + "gorm.io/gorm" + + "wx_service/internal/achievement" +) + +func (s *Service) ListAchievementThemes(ctx context.Context) ([]achievement.Theme, error) { + var themes []achievement.Theme + err := s.db.WithContext(ctx). + Preload("Levels", func(db *gorm.DB) *gorm.DB { + return db.Order("required_days ASC, sort_order ASC") + }). + Order("sort_order ASC, id ASC"). + Find(&themes).Error + if err != nil { + return nil, fmt.Errorf("list themes: %w", err) + } + return themes, nil +} + +func (s *Service) GetAchievementTheme(ctx context.Context, id uint) (*achievement.Theme, error) { + var theme achievement.Theme + err := s.db.WithContext(ctx). + Preload("Levels"). + First(&theme, id).Error + if err != nil { + return nil, err + } + return &theme, nil +} + +func (s *Service) CreateAchievementTheme(ctx context.Context, name, key, icon string, sortOrder int, isActive *bool) (*achievement.Theme, error) { + active := true + if isActive != nil { + active = *isActive + } + theme := achievement.Theme{ + Name: name, + Key: key, + Icon: icon, + SortOrder: sortOrder, + IsActive: active, + } + if err := s.db.WithContext(ctx).Create(&theme).Error; err != nil { + return nil, fmt.Errorf("create theme: %w", err) + } + return &theme, nil +} + +func (s *Service) UpdateAchievementTheme(ctx context.Context, id uint, name, key, icon string, sortOrder int, isActive *bool) (*achievement.Theme, error) { + var theme achievement.Theme + if err := s.db.WithContext(ctx).First(&theme, id).Error; err != nil { + return nil, err + } + if name != "" { + theme.Name = name + } + if key != "" { + theme.Key = key + } + if icon != "" { + theme.Icon = icon + } + theme.SortOrder = sortOrder + if isActive != nil { + theme.IsActive = *isActive + } + if err := s.db.WithContext(ctx).Save(&theme).Error; err != nil { + return nil, err + } + return &theme, nil +} + +func (s *Service) DeleteAchievementTheme(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&achievement.Theme{}, id).Error +} + +func (s *Service) ListAchievementLevels(ctx context.Context, themeID uint) ([]achievement.Level, error) { + var levels []achievement.Level + err := s.db.WithContext(ctx). + Where("theme_id = ?", themeID). + Order("required_days ASC, sort_order ASC"). + Find(&levels).Error + return levels, err +} + +func (s *Service) CreateAchievementLevel(ctx context.Context, themeID uint, name, icon string, requiredDays, sortOrder int) (*achievement.Level, error) { + level := achievement.Level{ + ThemeID: themeID, + Name: name, + Icon: icon, + RequiredDays: requiredDays, + SortOrder: sortOrder, + } + if err := s.db.WithContext(ctx).Create(&level).Error; err != nil { + return nil, err + } + return &level, nil +} + +func (s *Service) UpdateAchievementLevel(ctx context.Context, id uint, name, icon string, requiredDays, sortOrder int) (*achievement.Level, error) { + var level achievement.Level + if err := s.db.WithContext(ctx).First(&level, id).Error; err != nil { + return nil, err + } + if name != "" { + level.Name = name + } + if icon != "" { + level.Icon = icon + } + level.RequiredDays = requiredDays + level.SortOrder = sortOrder + if err := s.db.WithContext(ctx).Save(&level).Error; err != nil { + return nil, err + } + return &level, nil +} + +func (s *Service) DeleteAchievementLevel(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&achievement.Level{}, id).Error +} diff --git a/internal/admin/service/dream_preset_service.go b/internal/admin/service/dream_preset_service.go new file mode 100644 index 0000000..8ae802c --- /dev/null +++ b/internal/admin/service/dream_preset_service.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "fmt" + + quitmodel "wx_service/internal/quitcheckin/model" +) + +func (s *Service) ListDreamPresets(ctx context.Context) ([]quitmodel.DreamPreset, error) { + var presets []quitmodel.DreamPreset + err := s.db.WithContext(ctx). + Order("sort_order ASC, id ASC"). + Find(&presets).Error + if err != nil { + return nil, fmt.Errorf("list dream presets: %w", err) + } + return presets, nil +} + +func (s *Service) CreateDreamPreset(ctx context.Context, title, coverImage string, sortOrder int, isActive *bool) (*quitmodel.DreamPreset, error) { + active := true + if isActive != nil { + active = *isActive + } + preset := quitmodel.DreamPreset{ + Title: title, + CoverImage: coverImage, + SortOrder: sortOrder, + IsActive: active, + } + if err := s.db.WithContext(ctx).Create(&preset).Error; err != nil { + return nil, fmt.Errorf("create dream preset: %w", err) + } + return &preset, nil +} + +func (s *Service) UpdateDreamPreset(ctx context.Context, id uint, title, coverImage *string, sortOrder *int, isActive *bool) (*quitmodel.DreamPreset, error) { + var preset quitmodel.DreamPreset + if err := s.db.WithContext(ctx).First(&preset, id).Error; err != nil { + return nil, err + } + if title != nil { + preset.Title = *title + } + if coverImage != nil { + preset.CoverImage = *coverImage + } + if sortOrder != nil { + preset.SortOrder = *sortOrder + } + if isActive != nil { + preset.IsActive = *isActive + } + if err := s.db.WithContext(ctx).Save(&preset).Error; err != nil { + return nil, err + } + return &preset, nil +} + +func (s *Service) DeleteDreamPreset(ctx context.Context, id uint) error { + return s.db.WithContext(ctx).Delete(&quitmodel.DreamPreset{}, id).Error +} diff --git a/internal/quitcheckin/handler/handler.go b/internal/quitcheckin/handler/handler.go index 857eed4..b66b82a 100644 --- a/internal/quitcheckin/handler/handler.go +++ b/internal/quitcheckin/handler/handler.go @@ -330,6 +330,16 @@ func (h *Handler) ListRelapses(c *gin.Context) { c.JSON(http.StatusOK, model.Success(result)) } +// ListDreamPresets 获取管理员预设的梦想目标列表。 +func (h *Handler) ListDreamPresets(c *gin.Context) { + presets, err := h.service.ListDreamPresets(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取预设目标失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"items": presets})) +} + // ListRewardGoals 获取梦想目标列表。 // @Summary 获取梦想目标列表 // @Description 按状态筛选梦想目标,用于梦想实验室页面展示。 diff --git a/internal/quitcheckin/model/dream_preset.go b/internal/quitcheckin/model/dream_preset.go new file mode 100644 index 0000000..8e4fc4d --- /dev/null +++ b/internal/quitcheckin/model/dream_preset.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type DreamPreset struct { + ID uint `gorm:"primaryKey;comment:主键" json:"id"` + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"-"` + + Title string `gorm:"column:title;size:64;comment:图标名称" json:"title"` + CoverImage string `gorm:"column:cover_image;size:500;comment:图标(emoji或图片URL)" json:"cover_image"` + IsActive bool `gorm:"column:is_active;default:true;comment:是否启用" json:"is_active"` + SortOrder int `gorm:"column:sort_order;default:0;comment:排序" json:"sort_order"` +} + +func (DreamPreset) TableName() string { + return "fa_dream_goal_preset" +} diff --git a/internal/quitcheckin/service/service.go b/internal/quitcheckin/service/service.go index 136f2f6..59a8802 100644 --- a/internal/quitcheckin/service/service.go +++ b/internal/quitcheckin/service/service.go @@ -986,6 +986,9 @@ func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel } elapsedDays := daysBetween(normalizeDate(profile.QuitStartDate), today) + if currentStreak > elapsedDays { + elapsedDays = currentStreak + } theoreticalCigs := elapsedDays * profile.BaselineCigsPerDay avoidedCigs := theoreticalCigs - totalRelapseNum if avoidedCigs < 0 { @@ -1129,6 +1132,18 @@ func SortRelapses(items []RelapseEventResult) { }) } +// 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": {}, diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 70c35a8..c9c49f7 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -90,6 +90,21 @@ func registerAdminRoutes( protected.PUT("/smoke/motivation-quotes/:id", handler.UpdateSmokeMotivation) protected.DELETE("/smoke/motivation-quotes/:id", handler.DeleteSmokeMotivation) + protected.GET("/achievement/themes", handler.ListAchievementThemes) + protected.GET("/achievement/themes/:id", handler.GetAchievementTheme) + protected.POST("/achievement/themes", handler.CreateAchievementTheme) + protected.PUT("/achievement/themes/:id", handler.UpdateAchievementTheme) + protected.DELETE("/achievement/themes/:id", handler.DeleteAchievementTheme) + protected.GET("/achievement/themes/:id/levels", handler.ListAchievementLevels) + protected.POST("/achievement/levels", handler.CreateAchievementLevel) + protected.PUT("/achievement/levels/:id", handler.UpdateAchievementLevel) + protected.DELETE("/achievement/levels/:id", handler.DeleteAchievementLevel) + + protected.GET("/dream-presets", handler.ListDreamPresets) + protected.POST("/dream-presets", handler.CreateDreamPreset) + protected.PUT("/dream-presets/:id", handler.UpdateDreamPreset) + protected.DELETE("/dream-presets/:id", handler.DeleteDreamPreset) + protected.GET("/memberships/overview", handler.MembershipOverview) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes) diff --git a/internal/routes/quitcheckin_routes.go b/internal/routes/quitcheckin_routes.go index 674ddef..a151282 100644 --- a/internal/routes/quitcheckin_routes.go +++ b/internal/routes/quitcheckin_routes.go @@ -21,6 +21,7 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh v2.GET("/badges", handler.ListBadges) v2.GET("/relapses", handler.ListRelapses) + v2.GET("/dream-presets", handler.ListDreamPresets) v2.GET("/reward-goals", handler.ListRewardGoals) v2.POST("/reward-goals", handler.CreateRewardGoal) v2.PUT("/reward-goals/:id", handler.UpdateRewardGoal) diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index b550658..652e495 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -50,5 +50,9 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. smoke.GET("/quit-plan", quitPlanHandler.GetQuitPlan) smoke.GET("/quit-plan/days", quitPlanHandler.GetQuitPlanDays) smoke.POST("/quit-plan/reset", quitPlanHandler.ResetQuitPlan) + + // 成就系统 + smoke.GET("/achievement/themes", smokeHandler.ListAchievementThemes) + smoke.GET("/achievement", smokeHandler.GetAchievement) } } diff --git a/internal/smoke/handler/smoke_achievement_handler.go b/internal/smoke/handler/smoke_achievement_handler.go new file mode 100644 index 0000000..6d2a02f --- /dev/null +++ b/internal/smoke/handler/smoke_achievement_handler.go @@ -0,0 +1,69 @@ +package handler + +import ( + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/achievement" + "wx_service/internal/middleware" + "wx_service/internal/model" +) + +func (h *SmokeHandler) ListAchievementThemes(c *gin.Context) { + themes, err := h.achievementService.ListActiveThemes(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取成就主题失败")) + return + } + c.JSON(http.StatusOK, model.Success(gin.H{"themes": themes})) +} + +func (h *SmokeHandler) GetAchievement(c *gin.Context) { + user := middleware.MustCurrentUser(c) + ctx := c.Request.Context() + uid := int(user.ID) + now := time.Now() + + profile, err := h.smokeProfileService.Get(ctx, uid) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取用户信息失败")) + return + } + if profile == nil || profile.AchievementThemeID == nil { + c.JSON(http.StatusOK, model.Success(gin.H{"achievement": nil})) + return + } + + var days int + if profile.Mode == "quit" { + homeData, err := h.quitCheckinService.Home(ctx, uid, now) + if err != nil { + log.Printf("achievement: quitcheckin home err uid=%d: %v", uid, err) + days = 0 + } else { + days = homeData.Summary.CurrentStreakDays + } + } else { + streakDays, err := h.smokeLogService.GetStreakDays(ctx, uid, now) + if err != nil { + log.Printf("achievement: smoke streak err uid=%d: %v", uid, err) + days = 0 + } + days = streakDays + } + + ach, err := h.achievementService.GetUserAchievement(ctx, *profile.AchievementThemeID, days) + if err != nil { + if err == achievement.ErrThemeNotFound { + c.JSON(http.StatusOK, model.Success(gin.H{"achievement": nil})) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取成就信息失败")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{"achievement": ach})) +} diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 6e667db..996618a 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -10,8 +10,10 @@ import ( "github.com/gin-gonic/gin" + "wx_service/internal/achievement" "wx_service/internal/middleware" "wx_service/internal/model" + quitcheckinservice "wx_service/internal/quitcheckin/service" smokeservice "wx_service/internal/smoke/service" ) @@ -22,6 +24,8 @@ type SmokeHandler struct { smokeNextService *smokeservice.SmokeNextService smokeAINextService *smokeservice.SmokeAINextSmokeService smokeShareService *smokeservice.SmokeShareService + achievementService *achievement.Service + quitCheckinService *quitcheckinservice.Service } func NewSmokeHandler( @@ -31,6 +35,8 @@ func NewSmokeHandler( smokeNextService *smokeservice.SmokeNextService, smokeAINextService *smokeservice.SmokeAINextSmokeService, smokeShareService *smokeservice.SmokeShareService, + achievementService *achievement.Service, + quitCheckinService *quitcheckinservice.Service, ) *SmokeHandler { return &SmokeHandler{ smokeLogService: smokeLogService, @@ -39,6 +45,8 @@ func NewSmokeHandler( smokeNextService: smokeNextService, smokeAINextService: smokeAINextService, smokeShareService: smokeShareService, + achievementService: achievementService, + quitCheckinService: quitCheckinService, } } diff --git a/internal/smoke/handler/smoke_profile_handler.go b/internal/smoke/handler/smoke_profile_handler.go index 07acb96..2cb2152 100644 --- a/internal/smoke/handler/smoke_profile_handler.go +++ b/internal/smoke/handler/smoke_profile_handler.go @@ -27,6 +27,8 @@ type upsertSmokeProfileRequest struct { SleepTime *string `json:"sleep_time"` QuitDate *string `json:"quit_date"` + + AchievementThemeID *uint `json:"achievement_theme_id"` } func (h *SmokeHandler) GetProfile(c *gin.Context) { @@ -95,17 +97,21 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) { } } + achievementThemeIDProvided := req.AchievementThemeID != nil + view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{ - BaselineCigsPerDay: req.BaselineCigsPerDay, - SmokingYears: req.SmokingYears, - PackPriceCent: req.PackPriceCent, - Mode: req.Mode, - SmokeMotivations: req.SmokeMotivations, - QuitMotivations: req.QuitMotivations, - WakeUpTime: req.WakeUpTime, - SleepTime: req.SleepTime, - QuitDateProvided: quitDateProvided, - QuitDate: quitDate, + BaselineCigsPerDay: req.BaselineCigsPerDay, + SmokingYears: req.SmokingYears, + PackPriceCent: req.PackPriceCent, + Mode: req.Mode, + SmokeMotivations: req.SmokeMotivations, + QuitMotivations: req.QuitMotivations, + WakeUpTime: req.WakeUpTime, + SleepTime: req.SleepTime, + QuitDateProvided: quitDateProvided, + QuitDate: quitDate, + AchievementThemeIDProvided: achievementThemeIDProvided, + AchievementThemeID: req.AchievementThemeID, }) if err != nil { if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) { diff --git a/internal/smoke/model/smoke_profile.go b/internal/smoke/model/smoke_profile.go index 8c7a40a..6f84056 100644 --- a/internal/smoke/model/smoke_profile.go +++ b/internal/smoke/model/smoke_profile.go @@ -74,6 +74,8 @@ type SmokeUserProfile struct { QuitDate *time.Time `gorm:"column:quit_date;type:date;comment:目标戒烟日期" json:"quit_date,omitempty"` + AchievementThemeID *uint `gorm:"column:achievement_theme_id;comment:成就主题ID" json:"achievement_theme_id,omitempty"` + OnboardingCompletedAt *time.Time `gorm:"column:onboarding_completed_at;comment:首次补全完成时间" json:"onboarding_completed_at,omitempty"` } diff --git a/internal/smoke/service/smoke_profile_service.go b/internal/smoke/service/smoke_profile_service.go index f0e3ff4..784dc24 100644 --- a/internal/smoke/service/smoke_profile_service.go +++ b/internal/smoke/service/smoke_profile_service.go @@ -99,6 +99,9 @@ type UpsertSmokeProfileRequest struct { QuitDateProvided bool QuitDate *time.Time + + AchievementThemeIDProvided bool + AchievementThemeID *uint } func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) { @@ -166,6 +169,9 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo if req.QuitDateProvided { profile.QuitDate = req.QuitDate } + if req.AchievementThemeIDProvided { + profile.AchievementThemeID = req.AchievementThemeID + } now := time.Now() profile.Mode = normalizedSmokeMode(profile.Mode) diff --git a/internal/smoke/service/smoke_stats_service.go b/internal/smoke/service/smoke_stats_service.go index b6ee352..d2f6ade 100644 --- a/internal/smoke/service/smoke_stats_service.go +++ b/internal/smoke/service/smoke_stats_service.go @@ -252,6 +252,10 @@ func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end return int(count), nil } +func (s *SmokeLogService) GetStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) { + return s.computeStreakDays(ctx, uid, asOf) +} + func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) { asOf = dateOnly(asOf) start := asOf.AddDate(0, 0, -400) From a6f0bfd4e87dc860b73f15c1eb2c115ed23babb8 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Tue, 7 Apr 2026 22:10:31 +0800 Subject: [PATCH 2/2] feat(admin): add quit-checkin admin endpoints and smoke profile fields Made-with: Cursor --- .../handler/quit_checkin_admin_handler.go | 92 +++++++++ .../service/quit_checkin_admin_service.go | 179 ++++++++++++++++++ internal/admin/service/smoke_service.go | 4 + internal/routes/admin_routes.go | 3 + 4 files changed, 278 insertions(+) create mode 100644 internal/admin/handler/quit_checkin_admin_handler.go create mode 100644 internal/admin/service/quit_checkin_admin_service.go diff --git a/internal/admin/handler/quit_checkin_admin_handler.go b/internal/admin/handler/quit_checkin_admin_handler.go new file mode 100644 index 0000000..de34197 --- /dev/null +++ b/internal/admin/handler/quit_checkin_admin_handler.go @@ -0,0 +1,92 @@ +package handler + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + adminservice "wx_service/internal/admin/service" + "wx_service/internal/model" +) + +type quitDailyListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + DateFrom string `form:"date_from"` + DateTo string `form:"date_to"` +} + +// ListQuitDailyStatuses GET /api/admin/quit-checkin/daily-statuses +func (h *Handler) ListQuitDailyStatuses(c *gin.Context) { + var query quitDailyListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + dateFrom, err := parseDateOnly(query.DateFrom) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_from, expected YYYY-MM-DD")) + return + } + dateTo, err := parseDateOnly(query.DateTo) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid date_to, expected YYYY-MM-DD")) + return + } + + data, err := h.svc.ListQuitDailyStatuses(c.Request.Context(), adminservice.ListQuitDailyStatusesQuery{ + Page: query.Page, + PageSize: query.PageSize, + UID: query.UID, + DateFrom: dateFrom, + DateTo: dateTo, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load quit daily statuses failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} + +type quitRewardGoalsListQuery struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + UID int `form:"uid"` + Status string `form:"status"` +} + +// ListQuitRewardGoals GET /api/admin/quit-checkin/reward-goals +func (h *Handler) ListQuitRewardGoals(c *gin.Context) { + var query quitRewardGoalsListQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "invalid query")) + return + } + if query.Page == 0 { + query.Page = 1 + } + if query.PageSize == 0 { + query.PageSize = 20 + } + + data, err := h.svc.ListQuitRewardGoals(c.Request.Context(), adminservice.ListQuitRewardGoalsQuery{ + Page: query.Page, + PageSize: query.PageSize, + UID: query.UID, + Status: strings.TrimSpace(query.Status), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "load reward goals failed")) + return + } + c.JSON(http.StatusOK, model.Success(data)) +} diff --git a/internal/admin/service/quit_checkin_admin_service.go b/internal/admin/service/quit_checkin_admin_service.go new file mode 100644 index 0000000..bf6c3fe --- /dev/null +++ b/internal/admin/service/quit_checkin_admin_service.go @@ -0,0 +1,179 @@ +package service + +import ( + "context" + "strings" + "time" + + quitmodel "wx_service/internal/quitcheckin/model" +) + +// ListQuitDailyStatusesQuery 戒烟打卡每日状态列表查询。 +type ListQuitDailyStatusesQuery struct { + Page int + PageSize int + UID int + DateFrom *time.Time + DateTo *time.Time +} + +// QuitDailyStatusItem 管理端展示用(含 uid)。 +type QuitDailyStatusItem struct { + ID int `json:"id"` + UID int `json:"uid"` + Date string `json:"date"` + Status string `json:"status"` + CheckInAt *time.Time `json:"check_in_at,omitempty"` + RelapsedAt *time.Time `json:"relapsed_at,omitempty"` + RelapseNum int `json:"relapse_num"` + Reason string `json:"reason,omitempty"` + Note string `json:"note,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListQuitDailyStatusesResult 分页结果。 +type ListQuitDailyStatusesResult struct { + List []QuitDailyStatusItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ListQuitDailyStatuses 分页查询 fa_quit_checkin_daily_status。 +func (s *Service) ListQuitDailyStatuses(ctx context.Context, q ListQuitDailyStatusesQuery) (*ListQuitDailyStatusesResult, error) { + q.Page, q.PageSize = normalizePage(q.Page, q.PageSize) + + dbQuery := s.db.WithContext(ctx).Model(&quitmodel.DailyStatus{}) + if q.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", q.UID) + } + if q.DateFrom != nil { + dbQuery = dbQuery.Where("date >= ?", q.DateFrom.Format("2006-01-02")) + } + if q.DateTo != nil { + dbQuery = dbQuery.Where("date <= ?", q.DateTo.Format("2006-01-02")) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + var rows []quitmodel.DailyStatus + if total > 0 { + if err := dbQuery.Order("date DESC, id DESC"). + Limit(q.PageSize). + Offset((q.Page - 1) * q.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + list := make([]QuitDailyStatusItem, 0, len(rows)) + for _, r := range rows { + dateStr := "" + if !r.Date.IsZero() { + dateStr = r.Date.Format("2006-01-02") + } + list = append(list, QuitDailyStatusItem{ + ID: int(r.ID), + UID: r.UID, + Date: dateStr, + Status: r.Status, + CheckInAt: r.CheckInAt, + RelapsedAt: r.RelapsedAt, + RelapseNum: r.RelapseNum, + Reason: r.Reason, + Note: r.Note, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }) + } + + return &ListQuitDailyStatusesResult{ + List: list, + Total: total, + Page: q.Page, + PageSize: q.PageSize, + }, nil +} + +// ListQuitRewardGoalsQuery 用户梦想目标列表查询。 +type ListQuitRewardGoalsQuery struct { + Page int + PageSize int + UID int + Status string +} + +// QuitRewardGoalItem 管理端展示用。 +type QuitRewardGoalItem struct { + ID int `json:"id"` + UID int `json:"uid"` + Title string `json:"title"` + TargetAmountCent int `json:"target_amount_cent"` + CoverImage string `json:"cover_image,omitempty"` + Status string `json:"status"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListQuitRewardGoalsResult 分页结果。 +type ListQuitRewardGoalsResult struct { + List []QuitRewardGoalItem `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ListQuitRewardGoals 分页查询 fa_quit_checkin_reward_goal。 +func (s *Service) ListQuitRewardGoals(ctx context.Context, q ListQuitRewardGoalsQuery) (*ListQuitRewardGoalsResult, error) { + q.Page, q.PageSize = normalizePage(q.Page, q.PageSize) + + dbQuery := s.db.WithContext(ctx).Model(&quitmodel.RewardGoal{}) + if q.UID > 0 { + dbQuery = dbQuery.Where("uid = ?", q.UID) + } + if st := strings.TrimSpace(q.Status); st != "" && st != "all" { + dbQuery = dbQuery.Where("status = ?", st) + } + + var total int64 + if err := dbQuery.Count(&total).Error; err != nil { + return nil, err + } + + var rows []quitmodel.RewardGoal + if total > 0 { + if err := dbQuery.Order("id DESC"). + Limit(q.PageSize). + Offset((q.Page - 1) * q.PageSize). + Find(&rows).Error; err != nil { + return nil, err + } + } + + list := make([]QuitRewardGoalItem, 0, len(rows)) + for _, r := range rows { + list = append(list, QuitRewardGoalItem{ + ID: int(r.ID), + UID: r.UID, + Title: r.Title, + TargetAmountCent: r.TargetAmountCent, + CoverImage: r.CoverImage, + Status: r.Status, + CompletedAt: r.CompletedAt, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }) + } + + return &ListQuitRewardGoalsResult{ + List: list, + Total: total, + Page: q.Page, + PageSize: q.PageSize, + }, nil +} diff --git a/internal/admin/service/smoke_service.go b/internal/admin/service/smoke_service.go index b0d87ba..5c4f5ad 100644 --- a/internal/admin/service/smoke_service.go +++ b/internal/admin/service/smoke_service.go @@ -247,6 +247,7 @@ type ListSmokeProfilesQuery struct { type SmokeProfileItem struct { ID uint `json:"id"` UID int `json:"uid"` + Mode string `json:"mode,omitempty"` BaselineCigsPerDay int `json:"baseline_cigs_per_day"` SmokingYears float64 `json:"smoking_years"` PackPriceCent int `json:"pack_price_cent"` @@ -255,6 +256,7 @@ type SmokeProfileItem struct { WakeUpTime string `json:"wake_up_time"` SleepTime string `json:"sleep_time"` QuitDate *time.Time `json:"quit_date,omitempty"` + AchievementThemeID *uint `json:"achievement_theme_id,omitempty"` OnboardingCompletedAt *time.Time `json:"onboarding_completed_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -368,6 +370,7 @@ func convertSmokeProfile(row smokemodel.SmokeUserProfile) SmokeProfileItem { return SmokeProfileItem{ ID: row.ID, UID: row.UID, + Mode: row.Mode, BaselineCigsPerDay: row.BaselineCigsPerDay, SmokingYears: row.SmokingYears, PackPriceCent: row.PackPriceCent, @@ -376,6 +379,7 @@ func convertSmokeProfile(row smokemodel.SmokeUserProfile) SmokeProfileItem { WakeUpTime: row.WakeUpTime, SleepTime: row.SleepTime, QuitDate: row.QuitDate, + AchievementThemeID: row.AchievementThemeID, OnboardingCompletedAt: row.OnboardingCompletedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index c9c49f7..25dfdd9 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -105,6 +105,9 @@ func registerAdminRoutes( protected.PUT("/dream-presets/:id", handler.UpdateDreamPreset) protected.DELETE("/dream-presets/:id", handler.DeleteDreamPreset) + protected.GET("/quit-checkin/daily-statuses", handler.ListQuitDailyStatuses) + protected.GET("/quit-checkin/reward-goals", handler.ListQuitRewardGoals) + protected.GET("/memberships/overview", handler.MembershipOverview) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)