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
|
# 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,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 {
|
type SmokeProfileItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
UID int `json:"uid"`
|
UID int `json:"uid"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
BaselineCigsPerDay int `json:"baseline_cigs_per_day"`
|
BaselineCigsPerDay int `json:"baseline_cigs_per_day"`
|
||||||
SmokingYears float64 `json:"smoking_years"`
|
SmokingYears float64 `json:"smoking_years"`
|
||||||
PackPriceCent int `json:"pack_price_cent"`
|
PackPriceCent int `json:"pack_price_cent"`
|
||||||
@@ -255,6 +256,7 @@ type SmokeProfileItem struct {
|
|||||||
WakeUpTime string `json:"wake_up_time"`
|
WakeUpTime string `json:"wake_up_time"`
|
||||||
SleepTime string `json:"sleep_time"`
|
SleepTime string `json:"sleep_time"`
|
||||||
QuitDate *time.Time `json:"quit_date,omitempty"`
|
QuitDate *time.Time `json:"quit_date,omitempty"`
|
||||||
|
AchievementThemeID *uint `json:"achievement_theme_id,omitempty"`
|
||||||
OnboardingCompletedAt *time.Time `json:"onboarding_completed_at,omitempty"`
|
OnboardingCompletedAt *time.Time `json:"onboarding_completed_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -368,6 +370,7 @@ func convertSmokeProfile(row smokemodel.SmokeUserProfile) SmokeProfileItem {
|
|||||||
return SmokeProfileItem{
|
return SmokeProfileItem{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
UID: row.UID,
|
UID: row.UID,
|
||||||
|
Mode: row.Mode,
|
||||||
BaselineCigsPerDay: row.BaselineCigsPerDay,
|
BaselineCigsPerDay: row.BaselineCigsPerDay,
|
||||||
SmokingYears: row.SmokingYears,
|
SmokingYears: row.SmokingYears,
|
||||||
PackPriceCent: row.PackPriceCent,
|
PackPriceCent: row.PackPriceCent,
|
||||||
@@ -376,6 +379,7 @@ func convertSmokeProfile(row smokemodel.SmokeUserProfile) SmokeProfileItem {
|
|||||||
WakeUpTime: row.WakeUpTime,
|
WakeUpTime: row.WakeUpTime,
|
||||||
SleepTime: row.SleepTime,
|
SleepTime: row.SleepTime,
|
||||||
QuitDate: row.QuitDate,
|
QuitDate: row.QuitDate,
|
||||||
|
AchievementThemeID: row.AchievementThemeID,
|
||||||
OnboardingCompletedAt: row.OnboardingCompletedAt,
|
OnboardingCompletedAt: row.OnboardingCompletedAt,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
|||||||
@@ -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,24 @@ 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("/quit-checkin/daily-statuses", handler.ListQuitDailyStatuses)
|
||||||
|
protected.GET("/quit-checkin/reward-goals", handler.ListQuitRewardGoals)
|
||||||
|
|
||||||
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