feat: 戒烟成就、梦想图标预设、打卡统计与依赖注入调整
- 成就系统、连续打卡天数计算、管理后台成就 CRUD - 梦想目标图标预设 DreamPreset 与用户端 dream-presets 接口 - 管理后台梦想图标 CRUD;戒烟打卡 summary 修正 - 忽略根目录编译产物 /api Made-with: Cursor
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": {},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}))
|
||||||
|
}
|
||||||
@@ -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,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{
|
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,
|
||||||
PackPriceCent: req.PackPriceCent,
|
PackPriceCent: req.PackPriceCent,
|
||||||
Mode: req.Mode,
|
Mode: req.Mode,
|
||||||
SmokeMotivations: req.SmokeMotivations,
|
SmokeMotivations: req.SmokeMotivations,
|
||||||
QuitMotivations: req.QuitMotivations,
|
QuitMotivations: req.QuitMotivations,
|
||||||
WakeUpTime: req.WakeUpTime,
|
WakeUpTime: req.WakeUpTime,
|
||||||
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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user