Merge pull request #50 from hello-dd-code/develop
feat: 戒烟成就、打卡管理端与 profile 字段补全
This commit is contained in:
@@ -36,3 +36,4 @@ go.work
|
||||
# Local build binary
|
||||
wx_service
|
||||
wx_service_api
|
||||
/api
|
||||
|
||||
+10
-2
@@ -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)
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 按状态筛选梦想目标,用于梦想实验室页面展示。
|
||||
|
||||
@@ -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)
|
||||
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": {},
|
||||
|
||||
@@ -90,6 +90,24 @@ 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("/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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user