feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件 - 新增关注功能:follow 服务与 handler、FollowButton 组件 - 用户主页适配关注/粉丝与收藏展示 - flash 消息工具、广告与侧边栏组件微调
This commit is contained in:
@@ -82,6 +82,38 @@ func (h *Handlers) PostComments(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CommentLocation 评论定位:返回该评论的楼层号(含软删占位,与楼层分页口径一致),
|
||||
// 供通知/主页评论深链 #comment-{id} 跨页时换到正确页码。0 楼 = 评论不存在。
|
||||
func (h *Handlers) CommentLocation(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||||
return
|
||||
}
|
||||
// 待审/被拒帖子的评论不对公众开放
|
||||
var viewerID uint
|
||||
var loadActor func() *service.Actor
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
viewerID = claims.ID
|
||||
loadActor = h.actorLoader(claims.ID)
|
||||
}
|
||||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
floor := h.Comment.FloorNumber(uint(id), uint(cid))
|
||||
if floor <= 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "评论不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"floor": floor})
|
||||
}
|
||||
|
||||
// CreateCommentRequest 评论请求
|
||||
type CreateCommentRequest struct {
|
||||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||||
|
||||
69
backend/handler/favorite.go
Normal file
69
backend/handler/favorite.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ToggleFavorite 切换当前登录用户对帖子的收藏状态
|
||||
func (h *Handlers) ToggleFavorite(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
favorited, err := h.Favorite.Toggle(uint(id), claims.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"favorited": favorited})
|
||||
}
|
||||
|
||||
// UserFavorites 获取用户收藏的帖子列表(公开可见,仅 published)
|
||||
func (h *Handlers) UserFavorites(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
posts, total, err := h.Favorite.List(uint(id), page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 填充点赞状态(仅登录用户)
|
||||
if claims := middleware.CurrentUser(c); claims != nil {
|
||||
ids := make([]uint, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
ids = append(ids, p.ID)
|
||||
}
|
||||
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
|
||||
for i := range posts {
|
||||
posts[i].Liked = likedMap[posts[i].ID]
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": nonNilSlice(posts),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
58
backend/handler/follow.go
Normal file
58
backend/handler/follow.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ToggleFollow 切换当前登录用户对目标用户的关注状态
|
||||
func (h *Handlers) ToggleFollow(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
following, err := h.Follow.Toggle(claims.ID, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"following": following})
|
||||
}
|
||||
|
||||
// UserFollowing 获取用户关注的人列表(公开)
|
||||
func (h *Handlers) UserFollowing(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
if _, err := h.Auth.GetUserByID(uint(id)); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
|
||||
users, total, err := h.Follow.ListFollowing(uint(id), page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": nonNilSlice(users),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type Handlers struct {
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Like *service.LikeService
|
||||
Favorite *service.FavoriteService
|
||||
Follow *service.FollowService
|
||||
Notification *service.NotificationService
|
||||
OverviewSvc *service.OverviewService
|
||||
Checkin *service.CheckinService
|
||||
|
||||
@@ -88,6 +88,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
|
||||
}
|
||||
if claims != nil {
|
||||
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
|
||||
post.Favorited = h.Favorite.HasFavorited(post.ID, claims.ID)
|
||||
}
|
||||
h.attachNecroReplyHint(claims, post)
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
@@ -275,6 +276,7 @@ func (h *Handlers) UnlockPostPassword(c *gin.Context) {
|
||||
}
|
||||
if claims != nil {
|
||||
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
|
||||
post.Favorited = h.Favorite.HasFavorited(post.ID, claims.ID)
|
||||
}
|
||||
h.attachNecroReplyHint(claims, post)
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
|
||||
@@ -62,6 +62,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
sort := c.DefaultQuery("sort", "new")
|
||||
|
||||
var viewerID uint
|
||||
var actor *service.Actor
|
||||
@@ -72,7 +73,7 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size)
|
||||
posts, postsTotal, err := h.Post.ListByUser(user.ID, viewerID, actor, page, size, sort)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -91,6 +92,9 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
|
||||
postCount, _ := h.Post.CountByUser(user.ID)
|
||||
commentCount, _ := h.Comment.CountByUser(user.ID)
|
||||
favoriteCount := h.Favorite.CountByUser(user.ID)
|
||||
followingCount := h.Follow.CountFollowing(user.ID)
|
||||
isFollowing := viewerID > 0 && viewerID != user.ID && h.Follow.IsFollowing(viewerID, user.ID)
|
||||
|
||||
// 签到积分概览(失败不阻塞资料返回)
|
||||
pointsTotal, streak := 0, 0
|
||||
@@ -124,6 +128,12 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
if actor != nil && actor.HasPerm(service.PermUsers) {
|
||||
userPayload["banned"] = user.Banned
|
||||
}
|
||||
// 访客与该用户的关注关系(关注按钮初始态)
|
||||
userPayload["is_following"] = isFollowing
|
||||
// 邮箱仅本人可见,打码展示(j***@163.com)
|
||||
if viewerID == user.ID && user.Email != "" {
|
||||
userPayload["email"] = maskEmail(user.Email)
|
||||
}
|
||||
// 徽章墙(失败不阻塞资料返回)
|
||||
if h.Badge != nil {
|
||||
if badges, err := h.Badge.ForUser(user.ID); err == nil && badges != nil {
|
||||
@@ -134,10 +144,12 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": userPayload,
|
||||
"stats": gin.H{
|
||||
"post_count": postCount,
|
||||
"comment_count": commentCount,
|
||||
"points": pointsTotal,
|
||||
"streak": streak,
|
||||
"post_count": postCount,
|
||||
"comment_count": commentCount,
|
||||
"points": pointsTotal,
|
||||
"streak": streak,
|
||||
"favorite_count": favoriteCount,
|
||||
"following_count": followingCount,
|
||||
},
|
||||
"posts": nonNilSlice(posts),
|
||||
"posts_total": postsTotal,
|
||||
@@ -146,6 +158,21 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// maskEmail 邮箱打码:保留首字符与 @ 后域名(j***@163.com)
|
||||
func maskEmail(email string) string {
|
||||
at := -1
|
||||
for i, r := range email {
|
||||
if r == '@' {
|
||||
at = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if at <= 0 {
|
||||
return "***"
|
||||
}
|
||||
return email[:1] + "***" + email[at:]
|
||||
}
|
||||
|
||||
// UserComments 获取用户发表的评论列表(分页,含帖子标题)
|
||||
func (h *Handlers) UserComments(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -57,7 +57,7 @@ func InitDB(dsn string) error {
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
|
||||
@@ -432,6 +432,22 @@ type Like struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Favorite 收藏记录(联合唯一索引防止重复收藏;无计数列,按钮仅表示状态)
|
||||
type Favorite struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_fav_post_user;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Follow 关注关系(FollowerID 关注了 FollowingID;联合唯一防止重复关注)
|
||||
type Follow struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
FollowerID uint `gorm:"uniqueIndex:idx_follow_pair,priority:1;index;not null" json:"follower_id"`
|
||||
FollowingID uint `gorm:"uniqueIndex:idx_follow_pair,priority:2;index;not null" json:"following_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Checkin 每日签到记录(用户+日期联合唯一,防止重复签到)
|
||||
type Checkin struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -453,6 +469,7 @@ const (
|
||||
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
|
||||
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
|
||||
NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0)
|
||||
NotificationTypeFollow = "follow" // 关注了你
|
||||
)
|
||||
|
||||
// Notification 站内通知
|
||||
|
||||
@@ -66,6 +66,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
commentSvc := service.NewCommentService(model.DB).WithSetting(settingSvc)
|
||||
likeSvc := service.NewLikeService(model.DB)
|
||||
notifSvc := service.NewNotificationService(model.DB)
|
||||
favoriteSvc := service.NewFavoriteService(model.DB).WithPosts(postSvc)
|
||||
followSvc := service.NewFollowService(model.DB).WithNotification(notifSvc)
|
||||
overviewSvc := service.NewOverviewService(model.DB)
|
||||
checkinSvc := service.NewCheckinService(model.DB).WithSetting(settingSvc)
|
||||
announcementSvc := service.NewAnnouncementService(model.DB)
|
||||
@@ -108,6 +110,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Post: postSvc,
|
||||
Comment: commentSvc,
|
||||
Like: likeSvc,
|
||||
Favorite: favoriteSvc,
|
||||
Follow: followSvc,
|
||||
Notification: notifSvc,
|
||||
OverviewSvc: overviewSvc,
|
||||
Checkin: checkinSvc,
|
||||
@@ -170,8 +174,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.POST("/posts/:id/unlock-password", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateHidePassword), h.UnlockPostPassword)
|
||||
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||
pubAPI.GET("/posts/:id/comments/:cid", h.CommentLocation)
|
||||
pubAPI.GET("/users/:id", h.UserProfile)
|
||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||
pubAPI.GET("/users/:id/favorites", h.UserFavorites)
|
||||
pubAPI.GET("/users/:id/following", h.UserFollowing)
|
||||
pubAPI.GET("/announcements", h.AnnouncementsList)
|
||||
pubAPI.GET("/announcements/:id", h.AnnouncementDetail)
|
||||
pubAPI.GET("/pages", h.SitePagesList)
|
||||
@@ -220,6 +227,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
|
||||
api.PUT("/posts/:id/lock", h.TogglePostLock)
|
||||
api.POST("/posts/:id/like", h.ToggleLike)
|
||||
api.POST("/posts/:id/favorite", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ToggleFavorite)
|
||||
api.POST("/users/:id/follow", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ToggleFollow)
|
||||
api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment)
|
||||
api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment)
|
||||
api.DELETE("/posts/:id/comments/:cid", h.DeleteComment)
|
||||
|
||||
104
backend/service/favorite.go
Normal file
104
backend/service/favorite.go
Normal 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
104
backend/service/follow.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
|
||||
|
||||
Reference in New Issue
Block a user