diff --git a/pets-be/internal/handler/auth.go b/pets-be/internal/handler/auth.go index 22be448..96696e9 100644 --- a/pets-be/internal/handler/auth.go +++ b/pets-be/internal/handler/auth.go @@ -159,6 +159,30 @@ type updateProfileReq struct { } // UpdateProfile 更新资料 +// decorationReq 主页装扮。全部用指针:区分「没传」和「传了空串」—— +// 用户清空个性签名和不改个性签名是两件事 +type decorationReq struct { + Bio *string `json:"bio"` + BgFileID *string `json:"bg_file_id"` + Theme *string `json:"theme"` + ShowPets *bool `json:"show_pets"` +} + +// UpdateDecoration PUT /api/user/decoration 装扮自己的主页 +func (h *Handler) UpdateDecoration(c *gin.Context) { + var req decorationReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailParams(c, err.Error()) + return + } + user, err := h.svc.UpdateDecoration(middleware.UserID(c), req.Bio, req.BgFileID, req.Theme, req.ShowPets) + if err != nil { + respondErr(c, err) + return + } + response.OK(c, user) +} + func (h *Handler) UpdateProfile(c *gin.Context) { var req updateProfileReq if err := c.ShouldBindJSON(&req); err != nil { diff --git a/pets-be/internal/handler/helper.go b/pets-be/internal/handler/helper.go index 383b5b6..4a3f760 100644 --- a/pets-be/internal/handler/helper.go +++ b/pets-be/internal/handler/helper.go @@ -31,5 +31,9 @@ func respondErr(c *gin.Context, err error) { response.Fail(c, errcode.ErrForbidden, err.Error()) return } + if errors.Is(err, service.ErrInvalidParam) { + response.FailParams(c, err.Error()) + return + } response.FailErr(c, err) } diff --git a/pets-be/internal/model/user.go b/pets-be/internal/model/user.go index 4e6e145..dabce52 100644 --- a/pets-be/internal/model/user.go +++ b/pets-be/internal/model/user.go @@ -16,4 +16,13 @@ type User struct { Onboarded bool `json:"onboarded"` Disabled bool `json:"disabled"` IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用) + + // —— 个人主页装扮 —— + Bio string `gorm:"size:120" json:"bio"` // 个性签名 + BgFileID string `gorm:"size:24" json:"bg_file_id"` // 主页头图,关联 files 表 + BgURL string `gorm:"-" json:"bg_url"` // 计算字段:由 BgFileID 解析 + Theme string `gorm:"size:16" json:"theme"` // 主题色 key,见 service.UserThemes + // 存「隐藏」而不是「展示」,为的是零值即默认:老数据 AutoMigrate 出来是 0, + // 正好等于「展示宠物墙」,不需要给存量用户跑一次刷值 + HidePets bool `json:"hide_pets"` } diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index d0915de..54d74ef 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -48,6 +48,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.GET("/user/profile", h.Profile) g.PUT("/user/profile", h.UpdateProfile) g.GET("/user/summary", h.UserSummary) + g.PUT("/user/decoration", h.UpdateDecoration) g.GET("/pets", h.ListPets) g.POST("/pets", h.CreatePet) diff --git a/pets-be/internal/service/auth.go b/pets-be/internal/service/auth.go index 0a5e8d8..4bc5a6a 100644 --- a/pets-be/internal/service/auth.go +++ b/pets-be/internal/service/auth.go @@ -112,6 +112,10 @@ func (s *Service) GetUser(userID string) (*model.User, error) { return nil, err } u.AvatarURL = s.fileURL(u.AvatarFileID) + u.BgURL = s.fileURL(u.BgFileID) + if !UserThemes[u.Theme] { + u.Theme = defaultUserTheme // 老用户 theme 是空串,别让前端拿空 key 去查表 + } return &u, nil } diff --git a/pets-be/internal/service/service.go b/pets-be/internal/service/service.go index 44aba4d..ab96ec8 100644 --- a/pets-be/internal/service/service.go +++ b/pets-be/internal/service/service.go @@ -21,6 +21,9 @@ var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待") // ErrForbidden 越权操作(如删除别人的内容) var ErrForbidden = errors.New("无权操作") +// ErrInvalidParam 参数不合法(handler 据此返回 40000) +var ErrInvalidParam = errors.New("参数不合法") + // Service 业务逻辑聚合,方法按领域分散在各文件 type Service struct { db *gorm.DB diff --git a/pets-be/internal/service/user_profile.go b/pets-be/internal/service/user_profile.go index 8ab6723..e2233b3 100644 --- a/pets-be/internal/service/user_profile.go +++ b/pets-be/internal/service/user_profile.go @@ -1,9 +1,40 @@ package service import ( + "time" + "github.com/sundynix/pets-be/internal/model" ) +// UserThemes 主页可选主题色。存 key 不存色值——色值属于前端表现层, +// 存进库以后想调色就得改数据;而且 key 还能保证前端只渲染已知的几种。 +var UserThemes = map[string]bool{ + "warm": true, "mint": true, "sky": true, "violet": true, "ink": true, +} + +const defaultUserTheme = "warm" + +// PetCardBrief 主页宠物名片墙上的一只 +type PetCardBrief struct { + ID string `json:"id"` + Name string `json:"name"` + Emoji string `json:"emoji"` + AvatarURL string `json:"avatar_url"` + Type string `json:"type"` + Stage string `json:"stage"` + Age string `json:"age"` +} + +// KeepStats 养宠数据。三个数都按「这个人名下所有宠物」合并算, +// 不按单只——主页展示的是这个铲屎官本人,不是某一只宠物 +type KeepStats struct { + Days int `json:"days"` // 养宠天数:最早那只的建档日至今 + Records int `json:"records"` // 累计记录条数 + ActDays int `json:"act_days"` // 有记录的天数 + Streak int `json:"streak"` // 连续打卡天数(截至今天或昨天) + PetCount int `json:"pet_count"` // 在养几只 +} + // UserCard 用户公开资料。关注这件事之前只做了「点一下关注」, // 关注完既看不到对方主页、也看不到自己关注了谁——等于关注完就石沉大海。 type UserCard struct { @@ -16,6 +47,14 @@ type UserCard struct { Posts int64 `json:"post_count"` Following int64 `json:"following_count"` Followers int64 `json:"follower_count"` + + // —— 装扮 ——(列表接口不填,只有主页详情填) + Bio string `json:"bio,omitempty"` + BgURL string `json:"bg_url,omitempty"` + Theme string `json:"theme,omitempty"` + ShowPets bool `json:"show_pets"` + Pets []PetCardBrief `json:"pets,omitempty"` + Stats *KeepStats `json:"stats,omitempty"` } // GetUserCard 取某人的公开资料 + 三个计数 + 我是否已关注 @@ -42,9 +81,111 @@ func (s *Service) GetUserCard(viewerID, userID string) (*UserCard, error) { Where("follower_id = ? AND followee_id = ?", viewerID, userID).Count(&n) card.Followed = n > 0 } + + card.Bio = u.Bio + card.BgURL = s.fileURL(u.BgFileID) + card.Theme = u.Theme + if !UserThemes[card.Theme] { + card.Theme = defaultUserTheme + } + card.ShowPets = !u.HidePets + + stats, pets := s.keepStats(userID, card.ShowPets) + card.Stats = stats + card.Pets = pets return card, nil } +// keepStats 养宠数据 + (可选)宠物名片墙。 +// 两件事合在一个方法里是因为它们查的是同一批 pets,分开写要多查一次。 +func (s *Service) keepStats(userID string, withPets bool) (*KeepStats, []PetCardBrief) { + var pets []model.Pet + s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets) + + st := &KeepStats{PetCount: len(pets)} + cards := []PetCardBrief{} + if len(pets) == 0 { + return st, cards + } + + ids := make([]string, 0, len(pets)) + for _, p := range pets { + ids = append(ids, p.ID) + if withPets { + cards = append(cards, PetCardBrief{ + ID: p.ID, Name: p.Name, Emoji: p.Emoji, Type: p.Type, + Stage: p.Stage, Age: p.Age, + AvatarURL: s.fileURL(p.AvatarFileID), + }) + } + } + if !withPets { + cards = nil + } + + // 养宠天数按最早那只的建档日算。用建档日而不是生日:这里要表达的是 + // 「你在这个 App 上照顾它多久了」,不是宠物活了多久 + st.Days = int(time.Since(pets[0].CreatedAt).Hours()/24) + 1 + + var total int64 + s.db.Model(&model.HealthRecord{}).Where("pet_id IN ?", ids).Count(&total) + st.Records = int(total) + + // 有记录的日期,倒序。连续打卡从今天(或昨天,当天还没记也不该断)往回数 + var days []string + s.db.Model(&model.HealthRecord{}). + Where("pet_id IN ?", ids). + Distinct("DATE(occurred_at) as d"). + Order("d desc"). + Pluck("d", &days) + st.ActDays = len(days) + + has := make(map[string]bool, len(days)) + for _, d := range days { + if len(d) > 10 { + d = d[:10] // 有的驱动把 DATE() 读回来带时间部分 + } + has[d] = true + } + cur := time.Now() + if !has[cur.Format("2006-01-02")] { + cur = cur.AddDate(0, 0, -1) // 今天还没记不算断签,从昨天起算 + } + for has[cur.Format("2006-01-02")] { + st.Streak++ + cur = cur.AddDate(0, 0, -1) + } + return st, cards +} + +// UpdateDecoration 改主页装扮。只认这四个字段,nil 表示不动 +func (s *Service) UpdateDecoration(userID string, bio, bgFileID, theme *string, showPets *bool) (*model.User, error) { + fields := map[string]any{} + if bio != nil { + b := []rune(*bio) + if len(b) > 60 { + b = b[:60] + } + fields["bio"] = string(b) + } + if bgFileID != nil { + fields["bg_file_id"] = *bgFileID + } + if theme != nil { + if !UserThemes[*theme] { + return nil, ErrInvalidParam + } + fields["theme"] = *theme + } + if showPets != nil { + fields["hide_pets"] = !*showPets + } + if len(fields) == 0 { + return s.GetUser(userID) + } + return s.UpdateUser(userID, fields) +} + // ListUserPosts 某人发布的帖子 func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]model.Post, int64, error) { q := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published")