feat: 实现关注系统 + 通知侧边栏分类 TAB + 点赞聚合通知
- 新增关注系统:UserFollow 模型、FollowService(toggle/status/列表隐私控制) - User 新增 FollowersCount/FollowingCount/FollowListPublic/NotifyPrefs 字段 - Space 页面增加四态关注按钮(关注/已关注/回关/已互粉)+ 粉丝/关注数链接 - 新增 /space/:uid/followers 和 /space/:uid/following 列表页 - 新增 NotifyFollow/NotifyLikeAggregated 通知类型 - ReactionService 点赞时写入 daily_like_summary,访问时生成聚合通知 - FollowService 关注时触发 NotifyFollow 通知 - 消息中心侧边栏升级为分类 TAB(全部/系统通知/@艾特/点赞/关注) - NotificationService 新增 ListByCategory 按分类分页查询
This commit is contained in:
213
internal/service/follow_service.go
Normal file
213
internal/service/follow_service.go
Normal file
@ -0,0 +1,213 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FollowStatus 关注关系状态
|
||||
type FollowStatus struct {
|
||||
IFollow bool `json:"i_follow"`
|
||||
TheyFollow bool `json:"they_follow"`
|
||||
}
|
||||
|
||||
// FollowListResult 关注/粉丝列表结果
|
||||
type FollowListResult struct {
|
||||
Items []model.UserFollow `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
ListPublic bool `json:"list_public"`
|
||||
Accessible bool `json:"accessible"`
|
||||
}
|
||||
|
||||
// followNotifier 关注通知接口(ISP)
|
||||
type followNotifier interface {
|
||||
NotifyFollow(followerID, followeeID uint)
|
||||
}
|
||||
|
||||
// FollowService 关注业务逻辑
|
||||
type FollowService struct {
|
||||
db *gorm.DB
|
||||
repo FollowStore
|
||||
notifier followNotifier
|
||||
}
|
||||
|
||||
// NewFollowService 构造函数
|
||||
func NewFollowService(db *gorm.DB, repo FollowStore) *FollowService {
|
||||
return &FollowService{db: db, repo: repo}
|
||||
}
|
||||
|
||||
// SetNotifier 注入通知服务
|
||||
func (s *FollowService) SetNotifier(n followNotifier) {
|
||||
s.notifier = n
|
||||
}
|
||||
|
||||
// Toggle 关注/取消关注,返回操作后的关注状态(true=已关注)
|
||||
func (s *FollowService) Toggle(followerID, followeeID uint) (bool, error) {
|
||||
if followerID == followeeID {
|
||||
return false, fmt.Errorf("不能关注自己")
|
||||
}
|
||||
|
||||
var isFollowing bool
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
exists, err := txRepo.Exists(followerID, followeeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询关注状态失败: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
if err := txRepo.Delete(followerID, followeeID); err != nil {
|
||||
return fmt.Errorf("取消关注失败: %w", err)
|
||||
}
|
||||
if err := txRepo.IncrFollowingCount(followerID, -1); err != nil {
|
||||
return fmt.Errorf("更新关注数失败: %w", err)
|
||||
}
|
||||
if err := txRepo.IncrFollowersCount(followeeID, -1); err != nil {
|
||||
return fmt.Errorf("更新粉丝数失败: %w", err)
|
||||
}
|
||||
isFollowing = false
|
||||
} else {
|
||||
follow := &model.UserFollow{
|
||||
FollowerID: followerID,
|
||||
FolloweeID: followeeID,
|
||||
}
|
||||
if err := txRepo.Create(follow); err != nil {
|
||||
return fmt.Errorf("关注失败: %w", err)
|
||||
}
|
||||
if err := txRepo.IncrFollowingCount(followerID, 1); err != nil {
|
||||
return fmt.Errorf("更新关注数失败: %w", err)
|
||||
}
|
||||
if err := txRepo.IncrFollowersCount(followeeID, 1); err != nil {
|
||||
return fmt.Errorf("更新粉丝数失败: %w", err)
|
||||
}
|
||||
isFollowing = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && isFollowing {
|
||||
s.sendFollowNotification(followerID, followeeID)
|
||||
}
|
||||
|
||||
return isFollowing, err
|
||||
}
|
||||
|
||||
// sendFollowNotification 发送关注通知(非关键路径)
|
||||
func (s *FollowService) sendFollowNotification(followerID, followeeID uint) {
|
||||
if s.notifier != nil && followerID != followeeID {
|
||||
s.notifier.NotifyFollow(followerID, followeeID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus 查询当前用户对目标用户的关注关系
|
||||
func (s *FollowService) GetStatus(currentUserID, targetUserID uint) (*FollowStatus, error) {
|
||||
if currentUserID == 0 {
|
||||
return &FollowStatus{}, nil
|
||||
}
|
||||
|
||||
iFollow, err := s.repo.Exists(currentUserID, targetUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询我是否关注: %w", err)
|
||||
}
|
||||
|
||||
theyFollow, err := s.repo.Exists(targetUserID, currentUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询对方是否关注: %w", err)
|
||||
}
|
||||
|
||||
return &FollowStatus{
|
||||
IFollow: iFollow,
|
||||
TheyFollow: theyFollow,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListFollowers 粉丝列表(含隐私控制)
|
||||
func (s *FollowService) ListFollowers(userID, currentUserID uint, page, pageSize int) (*FollowListResult, error) {
|
||||
listPublic, err := s.repo.GetFollowListPublic(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询公开设置: %w", err)
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
accessible := listPublic || currentUserID == userID
|
||||
|
||||
var items []model.UserFollow
|
||||
var total int64
|
||||
|
||||
if accessible {
|
||||
items, total, err = s.repo.ListFollowers(userID, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询粉丝列表: %w", err)
|
||||
}
|
||||
} else {
|
||||
total, err = s.repo.CountFollowers(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询粉丝数: %w", err)
|
||||
}
|
||||
items = []model.UserFollow{}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []model.UserFollow{}
|
||||
}
|
||||
|
||||
return &FollowListResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: common.PageCount(total, p.PageSize),
|
||||
ListPublic: listPublic,
|
||||
Accessible: accessible,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListFollowing 关注列表(含隐私控制)
|
||||
func (s *FollowService) ListFollowing(userID, currentUserID uint, page, pageSize int) (*FollowListResult, error) {
|
||||
listPublic, err := s.repo.GetFollowListPublic(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询公开设置: %w", err)
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
accessible := listPublic || currentUserID == userID
|
||||
|
||||
var items []model.UserFollow
|
||||
var total int64
|
||||
|
||||
if accessible {
|
||||
items, total, err = s.repo.ListFollowing(userID, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询关注列表: %w", err)
|
||||
}
|
||||
} else {
|
||||
total, err = s.repo.CountFollowing(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询关注数: %w", err)
|
||||
}
|
||||
items = []model.UserFollow{}
|
||||
}
|
||||
|
||||
if items == nil {
|
||||
items = []model.UserFollow{}
|
||||
}
|
||||
|
||||
return &FollowListResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: common.PageCount(total, p.PageSize),
|
||||
ListPublic: listPublic,
|
||||
Accessible: accessible,
|
||||
}, nil
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
@ -9,10 +12,16 @@ import (
|
||||
type notifRepo interface {
|
||||
Create(n *model.Notification) error
|
||||
ListByUser(userID uint, offset, limit int) ([]model.Notification, error)
|
||||
ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error)
|
||||
CountByUser(userID uint) (int64, error)
|
||||
CountByUserAndType(userID uint, notifyType string) (int64, error)
|
||||
CountUnread(userID uint) (int64, error)
|
||||
CountUnreadByType(userID uint, notifyType string) (int64, error)
|
||||
MarkRead(id, userID uint) error
|
||||
MarkAllRead(userID uint) error
|
||||
GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error)
|
||||
MarkDailyLikeSummaryNotified(id uint) error
|
||||
GetUsernameByID(userID uint) (string, error)
|
||||
}
|
||||
|
||||
// NotificationService 消息/通知业务逻辑
|
||||
@ -39,8 +48,11 @@ func (s *NotificationService) Create(userID uint, notifyType, title, content str
|
||||
return s.repo.Create(n)
|
||||
}
|
||||
|
||||
// List 分页查询用户消息列表
|
||||
// List 分页查询用户消息列表(触发聚合通知检查)
|
||||
func (s *NotificationService) List(userID uint, page, pageSize int) (*model.NotificationListResult, error) {
|
||||
// 自动触发点赞聚合通知生成
|
||||
s.checkAndNotifyAggregation(userID)
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
@ -69,6 +81,123 @@ func (s *NotificationService) List(userID uint, page, pageSize int) (*model.Noti
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListByCategory 按通知分类分页查询(供消息页分类 TAB 使用)
|
||||
func (s *NotificationService) ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error) {
|
||||
s.checkAndNotifyAggregation(userID)
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
// 分类→通知类型映射
|
||||
notifyTypes := categoryTypes(category)
|
||||
var combinedItems []model.Notification
|
||||
var total int64
|
||||
|
||||
for _, nt := range notifyTypes {
|
||||
c, _ := s.repo.CountByUserAndType(userID, nt)
|
||||
total += c
|
||||
}
|
||||
|
||||
allItems := make([]model.Notification, 0)
|
||||
for _, nt := range notifyTypes {
|
||||
items, err := s.repo.ListByUserAndType(userID, nt, 0, 10000) // 大 limit,在内存里分页
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
allItems = append(allItems, items...)
|
||||
}
|
||||
|
||||
// 按 created_at 降序排序
|
||||
sortNotifications(allItems)
|
||||
|
||||
// 分页
|
||||
start := p.Offset()
|
||||
end := start + p.PageSize
|
||||
if start > len(allItems) {
|
||||
start = len(allItems)
|
||||
}
|
||||
if end > len(allItems) {
|
||||
end = len(allItems)
|
||||
}
|
||||
if start < end {
|
||||
combinedItems = allItems[start:end]
|
||||
}
|
||||
|
||||
if combinedItems == nil {
|
||||
combinedItems = []model.Notification{}
|
||||
}
|
||||
|
||||
totalPages := int((total + int64(p.PageSize) - 1) / int64(p.PageSize))
|
||||
unread, _ := s.CountUnread(userID)
|
||||
|
||||
return &model.NotificationListResult{
|
||||
Items: combinedItems,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: totalPages,
|
||||
Unread: unread,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkAndNotifyAggregation 检查并生成点赞聚合通知
|
||||
func (s *NotificationService) checkAndNotifyAggregation(userID uint) {
|
||||
summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID)
|
||||
if err != nil || len(summaries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sm := range summaries {
|
||||
count := countUIDs(sm.LikerUIDs)
|
||||
var content string
|
||||
if count <= 3 {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
} else {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyLikeAggregated,
|
||||
"点赞汇总通知",
|
||||
content,
|
||||
nil, nil)
|
||||
_ = s.repo.MarkDailyLikeSummaryNotified(sm.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func countUIDs(uids string) int {
|
||||
if uids == "" {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
for _, c := range uids {
|
||||
if c == ',' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// categoryTypes 分类→通知类型列表
|
||||
func categoryTypes(category string) []string {
|
||||
switch category {
|
||||
case "system":
|
||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
|
||||
case "mention":
|
||||
return []string{model.NotifyComment, model.NotifyCommentReply}
|
||||
case "like":
|
||||
return []string{model.NotifyLikeAggregated}
|
||||
case "follow":
|
||||
return []string{model.NotifyFollow}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// sortNotifications 按 CreatedAt 降序排序
|
||||
func sortNotifications(items []model.Notification) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// CountUnread 统计未读消息数
|
||||
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
||||
return s.repo.CountUnread(userID)
|
||||
@ -128,3 +257,25 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyFollow 关注通知(followerID 关注了 followeeID)
|
||||
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
|
||||
// 获取关注者用户名
|
||||
followerName := s.getUsername(followerID)
|
||||
if followerName == "" {
|
||||
followerName = "一位用户"
|
||||
}
|
||||
title := "关注通知"
|
||||
content := followerName + " 关注了你"
|
||||
relID := followerID
|
||||
_ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil)
|
||||
}
|
||||
|
||||
// getUsername 获取用户名(辅助方法)
|
||||
func (s *NotificationService) getUsername(userID uint) string {
|
||||
name, err := s.repo.GetUsernameByID(userID)
|
||||
if err != nil || name == "" {
|
||||
return "一位用户"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
@ -75,9 +76,29 @@ func (s *ReactionService) ToggleLike(userID, postID uint) (bool, error) {
|
||||
return nil
|
||||
})
|
||||
|
||||
if err == nil && isLiked {
|
||||
// 新赞 → 记录到每日点赞汇总(非关键路径)
|
||||
s.recordLikeForAggregation(postID, userID)
|
||||
}
|
||||
|
||||
return isLiked, err
|
||||
}
|
||||
|
||||
// recordLikeForAggregation 记录点赞到每日汇总(非关键路径,失败不阻塞)
|
||||
func (s *ReactionService) recordLikeForAggregation(postID uint, likerID uint) {
|
||||
authorID, err := s.repo.GetPostAuthorID(postID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 不记录自己赞自己
|
||||
if authorID == likerID {
|
||||
return
|
||||
}
|
||||
date := time.Now().Format("2006-01-02")
|
||||
likerUID := fmt.Sprintf("%d", likerID)
|
||||
_ = s.repo.UpsertDailyLikeSummary(authorID, date, likerUID)
|
||||
}
|
||||
|
||||
// ToggleDislike 切换踩(含赞互斥逻辑)
|
||||
// 返回:当前是否已踩
|
||||
func (s *ReactionService) ToggleDislike(userID, postID uint) (bool, error) {
|
||||
|
||||
@ -164,5 +164,24 @@ type ReactionStore interface {
|
||||
DeleteDislike(userID, postID uint) error
|
||||
IncrPostLikes(postID uint, delta int) error
|
||||
IncrPostDislikes(postID uint, delta int) error
|
||||
GetPostAuthorID(postID uint) (uint, error)
|
||||
UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error
|
||||
GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error)
|
||||
MarkSummaryNotified(id uint) error
|
||||
WithTx(tx *gorm.DB) ReactionStore
|
||||
}
|
||||
|
||||
// FollowStore FollowService 所需的最小仓储接口(ISP)
|
||||
type FollowStore interface {
|
||||
Create(follow *model.UserFollow) error
|
||||
Delete(followerID, followeeID uint) error
|
||||
Exists(followerID, followeeID uint) (bool, error)
|
||||
CountFollowers(userID uint) (int64, error)
|
||||
CountFollowing(userID uint) (int64, error)
|
||||
ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error)
|
||||
ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error)
|
||||
IncrFollowersCount(userID uint, delta int) error
|
||||
IncrFollowingCount(userID uint, delta int) error
|
||||
GetFollowListPublic(userID uint) (bool, error)
|
||||
WithTx(tx *gorm.DB) FollowStore
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user