feat: 戒烟成就、梦想图标预设、打卡统计与依赖注入调整

- 成就系统、连续打卡天数计算、管理后台成就 CRUD
- 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口
- 管理后台梦想图标 CRUD;戒烟打卡 summary 修正
- 忽略根目录编译产物 /api

Made-with: Cursor
This commit is contained in:
nepiedg
2026-04-04 14:55:50 +08:00
parent 1c0aeb152a
commit fd097729d7
20 changed files with 849 additions and 12 deletions
+1
View File
@@ -36,3 +36,4 @@ go.work
# Local build binary # Local build binary
wx_service wx_service
wx_service_api wx_service_api
/api
+10 -2
View File
@@ -30,6 +30,7 @@ import (
membershipservice "wx_service/internal/membership/service" membershipservice "wx_service/internal/membership/service"
"wx_service/internal/model" "wx_service/internal/model"
"wx_service/internal/observability" "wx_service/internal/observability"
"wx_service/internal/achievement"
quitcheckinhandler "wx_service/internal/quitcheckin/handler" quitcheckinhandler "wx_service/internal/quitcheckin/handler"
quitcheckinmodel "wx_service/internal/quitcheckin/model" quitcheckinmodel "wx_service/internal/quitcheckin/model"
quitcheckinservice "wx_service/internal/quitcheckin/service" quitcheckinservice "wx_service/internal/quitcheckin/service"
@@ -88,6 +89,9 @@ func main() {
&quitcheckinmodel.DailyStatus{}, &quitcheckinmodel.DailyStatus{},
&quitcheckinmodel.RelapseEvent{}, &quitcheckinmodel.RelapseEvent{},
&quitcheckinmodel.RewardGoal{}, &quitcheckinmodel.RewardGoal{},
&quitcheckinmodel.DreamPreset{},
&achievement.Theme{},
&achievement.Level{},
); err != nil { ); err != nil {
log.Fatalf("auto migrate failed: %v", err) log.Fatalf("auto migrate failed: %v", err)
} }
@@ -117,9 +121,13 @@ func main() {
smokeAINextService := smokeservice.NewSmokeAINextSmokeService(database.DB, config.AppConfig.AI) smokeAINextService := smokeservice.NewSmokeAINextSmokeService(database.DB, config.AppConfig.AI)
smokeShareService := smokeservice.NewSmokeShareService(database.DB) smokeShareService := smokeservice.NewSmokeShareService(database.DB)
smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI) smokeQuitPlanService := smokeservice.NewSmokeQuitPlanService(database.DB, config.AppConfig.AI)
smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService) achievementService := achievement.NewService(database.DB)
quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService) if err := achievementService.SeedDefaults(context.Background()); err != nil {
log.Printf("seed achievement defaults: %v", err)
}
quitCheckinService := quitcheckinservice.NewService(database.DB) quitCheckinService := quitcheckinservice.NewService(database.DB)
smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService, achievementService, quitCheckinService)
quitPlanHandler := smokehandler.NewQuitPlanHandler(smokeQuitPlanService)
quitCheckinHandler := quitcheckinhandler.NewHandler(quitCheckinService) quitCheckinHandler := quitcheckinhandler.NewHandler(quitCheckinService)
redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token)
+43
View File
@@ -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"
}
+187
View File
@@ -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
}
@@ -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}))
}
@@ -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}))
}
@@ -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
}
@@ -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
}
+10
View File
@@ -330,6 +330,16 @@ func (h *Handler) ListRelapses(c *gin.Context) {
c.JSON(http.StatusOK, model.Success(result)) 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 获取梦想目标列表。 // ListRewardGoals 获取梦想目标列表。
// @Summary 获取梦想目标列表 // @Summary 获取梦想目标列表
// @Description 按状态筛选梦想目标,用于梦想实验室页面展示。 // @Description 按状态筛选梦想目标,用于梦想实验室页面展示。
@@ -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"
}
+15
View File
@@ -986,6 +986,9 @@ func (s *Service) computeSummary(ctx context.Context, uid int, profile quitmodel
} }
elapsedDays := daysBetween(normalizeDate(profile.QuitStartDate), today) elapsedDays := daysBetween(normalizeDate(profile.QuitStartDate), today)
if currentStreak > elapsedDays {
elapsedDays = currentStreak
}
theoreticalCigs := elapsedDays * profile.BaselineCigsPerDay theoreticalCigs := elapsedDays * profile.BaselineCigsPerDay
avoidedCigs := theoreticalCigs - totalRelapseNum avoidedCigs := theoreticalCigs - totalRelapseNum
if avoidedCigs < 0 { 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 { func normalizeShowFields(items []string) []string {
allowed := map[string]struct{}{ allowed := map[string]struct{}{
"streak_days": {}, "streak_days": {},
+15
View File
@@ -90,6 +90,21 @@ func registerAdminRoutes(
protected.PUT("/smoke/motivation-quotes/:id", handler.UpdateSmokeMotivation) protected.PUT("/smoke/motivation-quotes/:id", handler.UpdateSmokeMotivation)
protected.DELETE("/smoke/motivation-quotes/:id", handler.DeleteSmokeMotivation) 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/overview", handler.MembershipOverview)
protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes) protected.GET("/memberships/redeem-codes", handler.ListMembershipRedeemCodes)
protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes) protected.POST("/memberships/redeem-codes", handler.CreateMembershipRedeemCodes)
+1
View File
@@ -21,6 +21,7 @@ func registerQuitCheckinRoutes(protected *gin.RouterGroup, handler *quitcheckinh
v2.GET("/badges", handler.ListBadges) v2.GET("/badges", handler.ListBadges)
v2.GET("/relapses", handler.ListRelapses) v2.GET("/relapses", handler.ListRelapses)
v2.GET("/dream-presets", handler.ListDreamPresets)
v2.GET("/reward-goals", handler.ListRewardGoals) v2.GET("/reward-goals", handler.ListRewardGoals)
v2.POST("/reward-goals", handler.CreateRewardGoal) v2.POST("/reward-goals", handler.CreateRewardGoal)
v2.PUT("/reward-goals/:id", handler.UpdateRewardGoal) v2.PUT("/reward-goals/:id", handler.UpdateRewardGoal)
+4
View File
@@ -50,5 +50,9 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
smoke.GET("/quit-plan", quitPlanHandler.GetQuitPlan) smoke.GET("/quit-plan", quitPlanHandler.GetQuitPlan)
smoke.GET("/quit-plan/days", quitPlanHandler.GetQuitPlanDays) smoke.GET("/quit-plan/days", quitPlanHandler.GetQuitPlanDays)
smoke.POST("/quit-plan/reset", quitPlanHandler.ResetQuitPlan) smoke.POST("/quit-plan/reset", quitPlanHandler.ResetQuitPlan)
// 成就系统
smoke.GET("/achievement/themes", smokeHandler.ListAchievementThemes)
smoke.GET("/achievement", smokeHandler.GetAchievement)
} }
} }
@@ -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}))
}
+8
View File
@@ -10,8 +10,10 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"wx_service/internal/achievement"
"wx_service/internal/middleware" "wx_service/internal/middleware"
"wx_service/internal/model" "wx_service/internal/model"
quitcheckinservice "wx_service/internal/quitcheckin/service"
smokeservice "wx_service/internal/smoke/service" smokeservice "wx_service/internal/smoke/service"
) )
@@ -22,6 +24,8 @@ type SmokeHandler struct {
smokeNextService *smokeservice.SmokeNextService smokeNextService *smokeservice.SmokeNextService
smokeAINextService *smokeservice.SmokeAINextSmokeService smokeAINextService *smokeservice.SmokeAINextSmokeService
smokeShareService *smokeservice.SmokeShareService smokeShareService *smokeservice.SmokeShareService
achievementService *achievement.Service
quitCheckinService *quitcheckinservice.Service
} }
func NewSmokeHandler( func NewSmokeHandler(
@@ -31,6 +35,8 @@ func NewSmokeHandler(
smokeNextService *smokeservice.SmokeNextService, smokeNextService *smokeservice.SmokeNextService,
smokeAINextService *smokeservice.SmokeAINextSmokeService, smokeAINextService *smokeservice.SmokeAINextSmokeService,
smokeShareService *smokeservice.SmokeShareService, smokeShareService *smokeservice.SmokeShareService,
achievementService *achievement.Service,
quitCheckinService *quitcheckinservice.Service,
) *SmokeHandler { ) *SmokeHandler {
return &SmokeHandler{ return &SmokeHandler{
smokeLogService: smokeLogService, smokeLogService: smokeLogService,
@@ -39,6 +45,8 @@ func NewSmokeHandler(
smokeNextService: smokeNextService, smokeNextService: smokeNextService,
smokeAINextService: smokeAINextService, smokeAINextService: smokeAINextService,
smokeShareService: smokeShareService, smokeShareService: smokeShareService,
achievementService: achievementService,
quitCheckinService: quitCheckinService,
} }
} }
@@ -27,6 +27,8 @@ type upsertSmokeProfileRequest struct {
SleepTime *string `json:"sleep_time"` SleepTime *string `json:"sleep_time"`
QuitDate *string `json:"quit_date"` QuitDate *string `json:"quit_date"`
AchievementThemeID *uint `json:"achievement_theme_id"`
} }
func (h *SmokeHandler) GetProfile(c *gin.Context) { func (h *SmokeHandler) GetProfile(c *gin.Context) {
@@ -95,6 +97,8 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
} }
} }
achievementThemeIDProvided := req.AchievementThemeID != nil
view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{ view, err := h.smokeProfileService.Upsert(c.Request.Context(), int(user.ID), smokeservice.UpsertSmokeProfileRequest{
BaselineCigsPerDay: req.BaselineCigsPerDay, BaselineCigsPerDay: req.BaselineCigsPerDay,
SmokingYears: req.SmokingYears, SmokingYears: req.SmokingYears,
@@ -106,6 +110,8 @@ func (h *SmokeHandler) UpsertProfile(c *gin.Context) {
SleepTime: req.SleepTime, SleepTime: req.SleepTime,
QuitDateProvided: quitDateProvided, QuitDateProvided: quitDateProvided,
QuitDate: quitDate, QuitDate: quitDate,
AchievementThemeIDProvided: achievementThemeIDProvided,
AchievementThemeID: req.AchievementThemeID,
}) })
if err != nil { if err != nil {
if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) { if errors.Is(err, smokeservice.ErrSmokeProfileInvalidTime) {
+2
View File
@@ -74,6 +74,8 @@ type SmokeUserProfile struct {
QuitDate *time.Time `gorm:"column:quit_date;type:date;comment:目标戒烟日期" json:"quit_date,omitempty"` 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"` OnboardingCompletedAt *time.Time `gorm:"column:onboarding_completed_at;comment:首次补全完成时间" json:"onboarding_completed_at,omitempty"`
} }
@@ -99,6 +99,9 @@ type UpsertSmokeProfileRequest struct {
QuitDateProvided bool QuitDateProvided bool
QuitDate *time.Time QuitDate *time.Time
AchievementThemeIDProvided bool
AchievementThemeID *uint
} }
func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) { 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 { if req.QuitDateProvided {
profile.QuitDate = req.QuitDate profile.QuitDate = req.QuitDate
} }
if req.AchievementThemeIDProvided {
profile.AchievementThemeID = req.AchievementThemeID
}
now := time.Now() now := time.Now()
profile.Mode = normalizedSmokeMode(profile.Mode) profile.Mode = normalizedSmokeMode(profile.Mode)
@@ -252,6 +252,10 @@ func (s *SmokeLogService) countResisted(ctx context.Context, uid int, start, end
return int(count), nil 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) { func (s *SmokeLogService) computeStreakDays(ctx context.Context, uid int, asOf time.Time) (int, error) {
asOf = dateOnly(asOf) asOf = dateOnly(asOf)
start := asOf.AddDate(0, 0, -400) start := asOf.AddDate(0, 0, -400)