diff --git a/cmd/api/main.go b/cmd/api/main.go index 63a3c96..3bf0895 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -73,6 +73,7 @@ func main() { &smokemodel.SmokeAIAdviceUnlock{}, &smokemodel.SmokeAINextSmoke{}, &smokemodel.SmokeMotivationQuote{}, + &smokemodel.SmokeShare{}, &marketingmodel.MarketingCategory{}, &marketingmodel.MarketingTemplate{}, &marketingmodel.MarketingDownload{}, @@ -103,7 +104,8 @@ func main() { smokeProfileService := smokeservice.NewSmokeProfileService(database.DB) smokeNextService := smokeservice.NewSmokeNextService(database.DB) smokeAINextService := smokeservice.NewSmokeAINextSmokeService(database.DB, config.AppConfig.AI) - smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService) + smokeShareService := smokeservice.NewSmokeShareService(database.DB) + smokeHandler := smokehandler.NewSmokeHandler(smokeLogService, smokeAIAdviceService, smokeProfileService, smokeNextService, smokeAINextService, smokeShareService) redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService) diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index 92b3922..75c641a 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -36,5 +36,10 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. // AI 下次抽烟时间建议(结构化时间节点) smoke.GET("/ai/next_smoke_time", smokeHandler.GetAINextSmokeTime) + + // 分享:用于给新用户只读展示统计与历史记录 + smoke.POST("/share", smokeHandler.CreateShare) + smoke.GET("/share/:token", smokeHandler.GetShareView) + smoke.POST("/share/:token/revoke", smokeHandler.RevokeShare) } } diff --git a/internal/smoke/handler/smoke_handler.go b/internal/smoke/handler/smoke_handler.go index 13892f0..6e667db 100644 --- a/internal/smoke/handler/smoke_handler.go +++ b/internal/smoke/handler/smoke_handler.go @@ -21,6 +21,7 @@ type SmokeHandler struct { smokeProfileService *smokeservice.SmokeProfileService smokeNextService *smokeservice.SmokeNextService smokeAINextService *smokeservice.SmokeAINextSmokeService + smokeShareService *smokeservice.SmokeShareService } func NewSmokeHandler( @@ -29,6 +30,7 @@ func NewSmokeHandler( smokeProfileService *smokeservice.SmokeProfileService, smokeNextService *smokeservice.SmokeNextService, smokeAINextService *smokeservice.SmokeAINextSmokeService, + smokeShareService *smokeservice.SmokeShareService, ) *SmokeHandler { return &SmokeHandler{ smokeLogService: smokeLogService, @@ -36,6 +38,7 @@ func NewSmokeHandler( smokeProfileService: smokeProfileService, smokeNextService: smokeNextService, smokeAINextService: smokeAINextService, + smokeShareService: smokeShareService, } } diff --git a/internal/smoke/handler/smoke_share_handler.go b/internal/smoke/handler/smoke_share_handler.go new file mode 100644 index 0000000..bb3942a --- /dev/null +++ b/internal/smoke/handler/smoke_share_handler.go @@ -0,0 +1,196 @@ +package handler + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "wx_service/internal/middleware" + "wx_service/internal/model" + smokeservice "wx_service/internal/smoke/service" +) + +type createSmokeShareRequest struct { + Days *int `json:"days"` +} + +func (h *SmokeHandler) CreateShare(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var req createSmokeShareRequest + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + days := 0 + if req.Days != nil { + days = *req.Days + } + + share, err := h.smokeShareService.Create(c.Request.Context(), int(user.ID), smokeservice.CreateSmokeShareRequest{Days: days}) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "创建分享失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "share_token": share.ShareToken, + "expire_at": share.ExpireAt.Format(time.RFC3339), + "share_path": fmt.Sprintf("/pages/share/index?share_token=%s", share.ShareToken), + })) +} + +func (h *SmokeHandler) GetShareView(c *gin.Context) { + token := strings.TrimSpace(c.Param("token")) + if token == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "share_token 不能为空")) + return + } + + share, err := h.smokeShareService.GetByToken(c.Request.Context(), token) + if err != nil { + switch { + case errors.Is(err, smokeservice.ErrSmokeShareNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分享不存在")) + case errors.Is(err, smokeservice.ErrSmokeShareExpired): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分享已过期")) + case errors.Is(err, smokeservice.ErrSmokeShareRevoked): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分享已失效")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "加载分享失败,请稍后重试")) + } + return + } + + _ = h.smokeShareService.TouchViewed(c.Request.Context(), share.ID) + + rangeType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("range", "week"))) + asOf := time.Now().In(time.Local) + if v := strings.TrimSpace(c.Query("date")); v != "" { + parsed, parseErr := time.ParseInLocation(dateLayout, v, time.Local) + if parseErr != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD")) + return + } + asOf = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, time.Local) + } + + statsReq, err := buildStatsRequest(rangeType, asOf) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error())) + return + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + listType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "all"))) + if listType != "all" && listType != "smoke" && listType != "resisted" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "type 应为 all|smoke|resisted")) + return + } + + owner, err := h.smokeShareService.GetOwnerPublic(c.Request.Context(), share.UID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "加载分享用户信息失败")) + return + } + + profile, err := h.smokeProfileService.Get(c.Request.Context(), share.UID) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取基础信息失败,请稍后重试")) + return + } + + stats, err := h.smokeLogService.Stats(c.Request.Context(), share.UID, statsReq, profile) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取统计数据失败,请稍后重试")) + return + } + + homeSummary, err := h.smokeLogService.HomeSummary(c.Request.Context(), share.UID, asOf) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试")) + return + } + + logs, err := h.smokeLogService.List(c.Request.Context(), share.UID, smokeservice.ListSmokeLogsRequest{ + Page: page, + PageSize: pageSize, + Type: listType, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询记录失败,请稍后重试")) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "owner": gin.H{ + "nickname": maskShareNickname(owner.NickName), + "avatar_url": owner.AvatarURL, + }, + "share": gin.H{ + "share_token": share.ShareToken, + "expire_at": share.ExpireAt, + "last_viewed_at": share.LastViewedAt, + "view_count": share.ViewCount + 1, + }, + "overview": gin.H{ + "today_count": homeSummary.TodayCount, + "resisted_count": homeSummary.ResistedCount, + "reduced_from_yesterday": homeSummary.ReducedFromYesterday, + "exceeded_yesterday": homeSummary.ExceededYesterday, + "last_smoke_at": homeSummary.LastSmokeAt, + "seconds_since_last": homeSummary.SecondsSinceLast, + "streak_days": stats.StreakDays, + }, + "stats": stats, + "logs": gin.H{ + "items": logs.Items, + "total": logs.Total, + "page": logs.Page, + "page_size": logs.PageSize, + }, + })) +} + +func (h *SmokeHandler) RevokeShare(c *gin.Context) { + user := middleware.MustCurrentUser(c) + token := strings.TrimSpace(c.Param("token")) + if token == "" { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "share_token 不能为空")) + return + } + + err := h.smokeShareService.Revoke(c.Request.Context(), int(user.ID), token) + if err != nil { + switch { + case errors.Is(err, smokeservice.ErrSmokeShareNotFound): + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "分享不存在")) + case errors.Is(err, smokeservice.ErrSmokeShareForbidden): + c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "无权限操作该分享")) + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "撤销分享失败,请稍后重试")) + } + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{"revoked": true})) +} + +func maskShareNickname(name string) string { + runes := []rune(strings.TrimSpace(name)) + if len(runes) <= 1 { + return "戒烟用户" + } + if len(runes) == 2 { + return string(runes[0]) + "*" + } + return string(runes[0]) + strings.Repeat("*", len(runes)-2) + string(runes[len(runes)-1]) +} diff --git a/internal/smoke/model/smoke_share.go b/internal/smoke/model/smoke_share.go new file mode 100644 index 0000000..732f439 --- /dev/null +++ b/internal/smoke/model/smoke_share.go @@ -0,0 +1,32 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// SmokeShare 存储用户主动创建的分享令牌,用于只读查看统计与记录。 +type SmokeShare 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:"-"` + + UID int `gorm:"index;comment:分享发起用户ID" json:"-"` + + ShareToken string `gorm:"column:share_token;size:64;uniqueIndex;comment:分享令牌" json:"share_token"` + ExpireAt time.Time `gorm:"column:expire_at;index;comment:过期时间" json:"expire_at"` + RevokedAt *time.Time `gorm:"column:revoked_at;comment:撤销时间" json:"revoked_at,omitempty"` + + LastViewedAt *time.Time `gorm:"column:last_viewed_at;comment:最近查看时间" json:"last_viewed_at,omitempty"` + ViewCount int64 `gorm:"column:view_count;default:0;comment:查看次数" json:"view_count"` +} + +func (SmokeShare) TableName() string { + return "fa_smoke_share" +} + +func (SmokeShare) TableComment() string { + return "戒烟分享链接" +} diff --git a/internal/smoke/service/smoke_share_service.go b/internal/smoke/service/smoke_share_service.go new file mode 100644 index 0000000..6c065c8 --- /dev/null +++ b/internal/smoke/service/smoke_share_service.go @@ -0,0 +1,170 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "wx_service/internal/model" + smokemodel "wx_service/internal/smoke/model" +) + +var ( + ErrSmokeShareNotFound = errors.New("smoke share not found") + ErrSmokeShareExpired = errors.New("smoke share expired") + ErrSmokeShareRevoked = errors.New("smoke share revoked") + ErrSmokeShareForbidden = errors.New("smoke share forbidden") +) + +type SmokeShareService struct { + db *gorm.DB +} + +type CreateSmokeShareRequest struct { + Days int +} + +type SmokeShareOwner struct { + NickName string `json:"nickname"` + AvatarURL string `json:"avatar_url"` +} + +func NewSmokeShareService(db *gorm.DB) *SmokeShareService { + return &SmokeShareService{db: db} +} + +func (s *SmokeShareService) Create(ctx context.Context, uid int, req CreateSmokeShareRequest) (*smokemodel.SmokeShare, error) { + days := normalizeShareDays(req.Days) + now := time.Now().In(time.Local) + + row := &smokemodel.SmokeShare{ + UID: uid, + ShareToken: generateShareToken(), + ExpireAt: now.AddDate(0, 0, days), + } + + if err := s.db.WithContext(ctx).Create(row).Error; err != nil { + return nil, fmt.Errorf("create smoke share: %w", err) + } + return row, nil +} + +func (s *SmokeShareService) GetByToken(ctx context.Context, token string) (*smokemodel.SmokeShare, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, ErrSmokeShareNotFound + } + + var row smokemodel.SmokeShare + if err := s.db.WithContext(ctx). + Where("share_token = ?", token). + First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrSmokeShareNotFound + } + return nil, fmt.Errorf("load smoke share by token: %w", err) + } + + if row.RevokedAt != nil { + return nil, ErrSmokeShareRevoked + } + if row.ExpireAt.Before(time.Now().In(time.Local)) { + return nil, ErrSmokeShareExpired + } + + return &row, nil +} + +func (s *SmokeShareService) Revoke(ctx context.Context, uid int, token string) error { + token = strings.TrimSpace(token) + if token == "" { + return ErrSmokeShareNotFound + } + + var row smokemodel.SmokeShare + if err := s.db.WithContext(ctx). + Where("share_token = ?", token). + First(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrSmokeShareNotFound + } + return fmt.Errorf("load smoke share for revoke: %w", err) + } + if row.UID != uid { + return ErrSmokeShareForbidden + } + if row.RevokedAt != nil { + return nil + } + + now := time.Now().In(time.Local) + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeShare{}). + Where("id = ?", row.ID). + Update("revoked_at", now).Error; err != nil { + return fmt.Errorf("revoke smoke share: %w", err) + } + return nil +} + +func (s *SmokeShareService) TouchViewed(ctx context.Context, id uint) error { + now := time.Now().In(time.Local) + if err := s.db.WithContext(ctx). + Model(&smokemodel.SmokeShare{}). + Where("id = ?", id). + Updates(map[string]any{ + "last_viewed_at": now, + "view_count": gorm.Expr("view_count + 1"), + }).Error; err != nil { + return fmt.Errorf("touch smoke share viewed: %w", err) + } + return nil +} + +func (s *SmokeShareService) GetOwnerPublic(ctx context.Context, uid int) (*SmokeShareOwner, error) { + var user model.User + if err := s.db.WithContext(ctx). + Select("id, nick_name, avatar_url"). + Where("id = ?", uid). + First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &SmokeShareOwner{NickName: "戒烟用户"}, nil + } + return nil, fmt.Errorf("load share owner: %w", err) + } + + name := strings.TrimSpace(user.NickName) + if name == "" { + name = "戒烟用户" + } + + return &SmokeShareOwner{ + NickName: name, + AvatarURL: user.AvatarURL, + }, nil +} + +func normalizeShareDays(days int) int { + if days <= 0 { + return 7 + } + if days > 30 { + return 30 + } + return days +} + +func generateShareToken() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + fallback := time.Now().UnixNano() + return fmt.Sprintf("%x", fallback) + } + return hex.EncodeToString(buf) +}