feat: 帖子收藏与用户关注

- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
2026-09-26 03:02:40 +08:00
parent 31f5360c26
commit d7c6b3a961
40 changed files with 1528 additions and 268 deletions

104
backend/service/favorite.go Normal file
View File

@@ -0,0 +1,104 @@
package service
import (
"errors"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// FavoriteService 收藏服务:收藏公开可见,公开列表只出 published 帖
type FavoriteService struct {
db *gorm.DB
posts *PostService
}
func NewFavoriteService(db *gorm.DB) *FavoriteService {
return &FavoriteService{db: db}
}
// WithPosts 注入帖子服务(列表复用 PostListItem 组装与最后回复填充)
func (s *FavoriteService) WithPosts(p *PostService) *FavoriteService {
s.posts = p
return s
}
// Toggle 切换收藏状态:已收藏则取消,未收藏则收藏。返回 (favorited, error)
func (s *FavoriteService) Toggle(postID, userID uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return false, errors.New("帖子不存在")
}
var fav model.Favorite
err := s.db.Where("post_id = ? AND user_id = ?", postID, userID).First(&fav).Error
if err == nil {
if err := s.db.Delete(&fav).Error; err != nil {
return true, err
}
return false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
if err := s.db.Create(&model.Favorite{PostID: postID, UserID: userID}).Error; err != nil {
return false, err
}
return true, nil
}
// HasFavorited 判断用户是否已收藏某帖子
func (s *FavoriteService) HasFavorited(postID, userID uint) bool {
var count int64
s.db.Model(&model.Favorite{}).Where("post_id = ? AND user_id = ?", postID, userID).Count(&count)
return count > 0
}
// BatchHasFavorited 批量查询用户对多个帖子的收藏状态,返回 postID -> bool
func (s *FavoriteService) BatchHasFavorited(postIDs []uint, userID uint) map[uint]bool {
result := make(map[uint]bool)
if len(postIDs) == 0 || userID == 0 {
return result
}
var favs []model.Favorite
s.db.Where("post_id IN ? AND user_id = ?", postIDs, userID).Find(&favs)
for _, f := range favs {
result[f.PostID] = true
}
return result
}
// List 用户收藏的帖子列表(公开可见,仅 published;按收藏时间倒序)
func (s *FavoriteService) List(userID uint, page, size int) ([]PostListItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.Post{}).
Joins("JOIN favorites ON favorites.post_id = posts.id").
Where("favorites.user_id = ? AND posts.status = ?", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (page - 1) * size
if err := query.Select("posts.*").Order("favorites.created_at DESC").Offset(offset).Limit(size).
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := toPostListItems(posts)
if s.posts != nil {
s.posts.fillLastReply(items)
}
return items, total, nil
}
// CountByUser 用户收藏数量
func (s *FavoriteService) CountByUser(userID uint) int64 {
var count int64
s.db.Model(&model.Favorite{}).Where("user_id = ?", userID).Count(&count)
return count
}

104
backend/service/follow.go Normal file
View File

@@ -0,0 +1,104 @@
package service
import (
"errors"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// FollowService 关注服务(仅用户;关注时给对方发通知)
type FollowService struct {
db *gorm.DB
notif *NotificationService
}
func NewFollowService(db *gorm.DB) *FollowService {
return &FollowService{db: db}
}
// WithNotification 注入通知服务(关注成功时落库一条 follow 通知)
func (s *FollowService) WithNotification(n *NotificationService) *FollowService {
s.notif = n
return s
}
// Toggle 切换关注状态:已关注则取关,未关注则关注。返回 (following, error)
func (s *FollowService) Toggle(followerID, targetID uint) (bool, error) {
if followerID == targetID {
return false, errors.New("不能关注自己")
}
var target model.User
if err := s.db.First(&target, targetID).Error; err != nil {
return false, errors.New("用户不存在")
}
if target.Banned {
return false, errors.New("该用户已被封禁")
}
var follow model.Follow
err := s.db.Where("follower_id = ? AND following_id = ?", followerID, targetID).First(&follow).Error
if err == nil {
if err := s.db.Delete(&follow).Error; err != nil {
return true, err
}
return false, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
if err := s.db.Create(&model.Follow{FollowerID: followerID, FollowingID: targetID}).Error; err != nil {
return false, err
}
if s.notif != nil {
s.notif.Create(targetID, followerID, model.NotificationTypeFollow, 0, 0, "")
}
return true, nil
}
// IsFollowing 判断 followerID 是否已关注 targetID
func (s *FollowService) IsFollowing(followerID, targetID uint) bool {
if followerID == 0 || targetID == 0 || followerID == targetID {
return false
}
var count int64
s.db.Model(&model.Follow{}).Where("follower_id = ? AND following_id = ?", followerID, targetID).Count(&count)
return count > 0
}
// ListFollowing 用户关注的人列表(公开;按关注时间倒序)
func (s *FollowService) ListFollowing(userID uint, page, size int) ([]model.User, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.User{}).
Joins("JOIN follows ON follows.following_id = users.id").
Where("follows.follower_id = ?", userID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []model.User
offset := (page - 1) * size
if err := query.Select("users.*").Order("follows.created_at DESC").Offset(offset).Limit(size).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// CountFollowing 关注数量
func (s *FollowService) CountFollowing(userID uint) int64 {
var count int64
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&count)
return count
}
// CountFollowers 粉丝数量
func (s *FollowService) CountFollowers(userID uint) int64 {
var count int64
s.db.Model(&model.Follow{}).Where("following_id = ?", userID).Count(&count)
return count
}

View File

@@ -91,13 +91,14 @@ type OverviewData struct {
// PublicAdItem 侧栏广告条目
type PublicAdItem struct {
ID uint `json:"id"`
Kind string `json:"kind"`
LinkURL string `json:"link_url"`
ImageURL string `json:"image_url,omitempty"`
Title string `json:"title,omitempty"`
TextColor string `json:"text_color,omitempty"`
BgColor string `json:"bg_color,omitempty"`
ID uint `json:"id"`
Kind string `json:"kind"`
LinkURL string `json:"link_url"`
ImageURL string `json:"image_url,omitempty"`
Title string `json:"title,omitempty"`
TextColor string `json:"text_color,omitempty"`
BgColor string `json:"bg_color,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
}
// PublicSponsorItem 侧栏赞助商
@@ -332,6 +333,7 @@ LIMIT 5`
ID: a.ID, Kind: a.Kind, LinkURL: a.LinkURL,
ImageURL: a.ImageURL, Title: a.Title,
TextColor: a.TextColor, BgColor: a.BgColor,
EndsAt: a.EndsAt,
})
}
}

View File

@@ -270,16 +270,31 @@ func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
return items, total, nil
}
// normalizeUserPostSort 用户主页帖子排序口径(非法值回落 new)
func normalizeUserPostSort(sort string) string {
switch sort {
case "hot", "essence":
return sort
default:
return "new"
}
}
// ListByUser 获取指定用户发布的帖子。
// viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。
func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) {
// sort: new=最新(默认,置顶优先)、hot=热门(点赞+评论加权)、essence=精华(仅 recommended)。
func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int, sort string) ([]PostListItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
sort = normalizeUserPostSort(sort)
query := s.db.Model(&model.Post{}).Where("user_id = ?", userID)
if sort == "essence" {
query = query.Where("recommended = ?", true)
}
if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) {
query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
} else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
@@ -296,7 +311,11 @@ func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size
}
var posts []model.Post
offset := (page - 1) * size
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
order := "pinned DESC, created_at DESC"
if sort == "hot" {
order = "like_count + comment_count DESC, view_count DESC, created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(size).
Preload("Board").Preload("User").Preload("User.Badges.Badge").Find(&posts).Error; err != nil {
return nil, 0, err
}
@@ -457,7 +476,8 @@ type PostDetail struct {
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框