feat: 补齐关注的入口——用户主页 + 关注/粉丝列表

关注之前只做了一半:能从帖子上点「+关注」,也有「关注」信息流,但
关注完就石沉大海——没有用户主页、看不到自己关注了谁、也不知道谁关注
了自己。功能是通的,路是断的。

后端新增:
- GET /api/users/:id/profile  公开资料 + 帖子/关注/粉丝三个计数 + 我是否已关注
- GET /api/users/:id/posts    TA 发布的帖子
- GET /api/users/:id/relations?kind=following|followers
- /api/user/summary 补 following / followers,否则「我的」页那两行永远是 0

关系列表里「我是否关注了这批人」用一次 IN 查询查完,不是每行一次。

小程序端新增两个页面:
- pages/user/user      用户主页:头像/昵称/三项计数/关注按钮 + TA 的帖子
- pages/relations      关注列表与粉丝列表(同一页,kind 区分)

入口:
- 社区点头像或昵称 → TA 的主页
- 主页点「关注/粉丝」数字 → 对应列表
- 「我的」页新增「我的关注」「我的粉丝」两行

关注按钮先本地翻转再发请求,失败回滚——等接口返回按钮才变会显得很迟钝。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 17:13:04 +08:00
parent b8b3ba99b4
commit 25f655a6fc
18 changed files with 415 additions and 3 deletions
+37
View File
@@ -182,3 +182,40 @@ func (h *Handler) ListReplies(c *gin.Context) {
}
response.OK(c, list)
}
// UserCard GET /api/users/:id/profile 某人的公开资料
func (h *Handler) UserCard(c *gin.Context) {
card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
// UserPosts GET /api/users/:id/posts 某人发布的帖子
func (h *Handler) UserPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// Relations GET /api/users/:id/relations?kind=following|followers
func (h *Handler) Relations(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
kind := c.Query("kind")
list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
+3
View File
@@ -88,6 +88,9 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage
g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster)
g.GET("/users/:id/profile", h.UserCard)
g.GET("/users/:id/posts", h.UserPosts)
g.GET("/users/:id/relations", h.Relations)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
+4
View File
@@ -216,6 +216,8 @@ type UserSummary struct {
Pets int64 `json:"pets"`
Records int64 `json:"records"`
Reminders int64 `json:"reminders"`
Following int64 `json:"following"` // 我关注了多少人
Followers int64 `json:"followers"` // 多少人关注我
ProStatus string `json:"pro_status"`
}
@@ -225,6 +227,8 @@ func (s *Service) GetUserSummary(userID string) (*UserSummary, error) {
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
sum.ProStatus = model.ProNone
var m model.ProMembership
+123
View File
@@ -0,0 +1,123 @@
package service
import (
"github.com/sundynix/pets-be/internal/model"
)
// UserCard 用户公开资料。关注这件事之前只做了「点一下关注」,
// 关注完既看不到对方主页、也看不到自己关注了谁——等于关注完就石沉大海。
type UserCard struct {
ID string `json:"id"`
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url"`
IsBot bool `json:"is_bot"`
IsSelf bool `json:"is_self"`
Followed bool `json:"followed"` // 我有没有关注 TA
Posts int64 `json:"post_count"`
Following int64 `json:"following_count"`
Followers int64 `json:"follower_count"`
}
// GetUserCard 取某人的公开资料 + 三个计数 + 我是否已关注
func (s *Service) GetUserCard(viewerID, userID string) (*UserCard, error) {
var u model.User
if err := s.db.First(&u, "id = ?", userID).Error; err != nil {
return nil, ErrNotFound
}
card := &UserCard{
ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
IsSelf: u.ID == viewerID,
AvatarURL: s.fileURL(u.AvatarFileID),
}
if card.AvatarURL == "" {
card.AvatarURL = u.Avatar
}
s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, "published").Count(&card.Posts)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&card.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&card.Followers)
if !card.IsSelf {
var n int64
s.db.Model(&model.Follow{}).
Where("follower_id = ? AND followee_id = ?", viewerID, userID).Count(&n)
card.Followed = n > 0
}
return card, nil
}
// 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")
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
return nil, 0, err
}
s.attachPostImages(posts)
s.markFollowed(viewerID, posts)
return posts, total, nil
}
// ListRelations 关注列表 / 粉丝列表。
// kind = "following" 我关注的人;"followers" 关注我的人
func (s *Service) ListRelations(viewerID, userID, kind string, offset, limit int) ([]UserCard, int64, error) {
q := s.db.Model(&model.Follow{})
var idCol string
if kind == "followers" {
q = q.Where("followee_id = ?", userID)
idCol = "follower_id"
} else {
q = q.Where("follower_id = ?", userID)
idCol = "followee_id"
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var ids []string
if err := q.Order("id desc").Offset(offset).Limit(limit).Pluck(idCol, &ids).Error; err != nil {
return nil, 0, err
}
if len(ids) == 0 {
return []UserCard{}, total, nil
}
var users []model.User
s.db.Where("id IN ?", ids).Find(&users)
byID := map[string]model.User{}
for _, u := range users {
byID[u.ID] = u
}
// 一次查出我关注了这批人里的哪些,不要每行一次查询
var mine []string
s.db.Model(&model.Follow{}).Where("follower_id = ? AND followee_id IN ?", viewerID, ids).
Pluck("followee_id", &mine)
followed := map[string]bool{}
for _, id := range mine {
followed[id] = true
}
// 按 ids 的顺序输出,保持「最近关注的在前」
out := make([]UserCard, 0, len(ids))
for _, id := range ids {
u, ok := byID[id]
if !ok {
continue
}
avatar := s.fileURL(u.AvatarFileID)
if avatar == "" {
avatar = u.Avatar
}
out = append(out, UserCard{
ID: u.ID, Nickname: u.Nickname, IsBot: u.IsBot,
AvatarURL: avatar,
IsSelf: u.ID == viewerID,
Followed: followed[u.ID],
})
}
return out, total, nil
}