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:
2026-06-01 16:30:46 +08:00
parent 2b47380ac5
commit 680df7371a
26 changed files with 1251 additions and 23 deletions

View File

@ -0,0 +1,188 @@
package controller
import (
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"github.com/gin-gonic/gin"
)
// FollowController 关注 API 控制器
type FollowController struct {
followSvc followUseCase
}
// NewFollowController 构造函数
func NewFollowController(followSvc followUseCase) *FollowController {
return &FollowController{followSvc: followSvc}
}
// Toggle 切换关注 POST /api/users/:uid/follow
func (fc *FollowController) Toggle(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
isFollowing, err := fc.followSvc.Toggle(uid, uint(targetUID))
if err != nil {
if err.Error() == "不能关注自己" {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
// 获取最新状态
status, _ := fc.followSvc.GetStatus(uid, uint(targetUID))
common.Ok(c, gin.H{
"is_following": isFollowing,
"status": status,
})
}
// GetStatus 查询关注关系 GET /api/users/:uid/follow-status
func (fc *FollowController) GetStatus(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
uid, _, _ := common.GetGinUser(c) // 允许未登录(返回全是 false
status, err := fc.followSvc.GetStatus(uid, uint(targetUID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, status)
}
// Followers 粉丝列表 GET /api/users/:uid/followers
func (fc *FollowController) Followers(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c) // 允许未登录
result, err := fc.followSvc.ListFollowers(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// Following 关注列表 GET /api/users/:uid/following
func (fc *FollowController) Following(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
uid, _, _ := common.GetGinUser(c)
result, err := fc.followSvc.ListFollowing(uint(targetUID), uid, page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
common.Ok(c, result)
}
// FollowPageController 关注/粉丝页面控制器
type FollowPageController struct {
followSvc followUseCase
}
// NewFollowPageController 构造函数
func NewFollowPageController(followSvc followUseCase) *FollowPageController {
return &FollowPageController{followSvc: followSvc}
}
// FollowersPage 粉丝列表页面
func (fpc *FollowPageController) FollowersPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowers(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0}
}
c.HTML(http.StatusOK, "follow/followers.html", common.BuildPageData(c, gin.H{
"Title": "粉丝列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}
// FollowingPage 关注列表页面
func (fpc *FollowPageController) FollowingPage(c *gin.Context) {
targetUID, err := strconv.ParseUint(c.Param("uid"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
uid, _, _ := common.GetGinUser(c)
result, err := fpc.followSvc.ListFollowing(uint(targetUID), uid, page, 20)
if err != nil {
result = &service.FollowListResult{Items: []model.UserFollow{}, Total: 0}
}
c.HTML(http.StatusOK, "follow/following.html", common.BuildPageData(c, gin.H{
"Title": "关注列表",
"ExtraCSS": "/static/css/follow.css",
"Result": result,
"TargetUID": targetUID,
"Page": result.Page,
"TotalPages": result.TotalPages,
"HasPrev": result.Page > 1,
"HasNext": result.Page < result.TotalPages,
"PrevPage": result.Page - 1,
"NextPage": result.Page + 1,
}))
}

View File

@ -111,3 +111,11 @@ type reactionUseCase interface {
GetReaction(userID, postID uint) (service.ReactionType, error)
GetPostLikesCount(postID uint) (int, error)
}
// followUseCase FollowController 对 FollowService 的最小依赖ISP4 个方法)
type followUseCase interface {
Toggle(followerID, followeeID uint) (bool, error)
GetStatus(currentUserID, targetUserID uint) (*service.FollowStatus, error)
ListFollowers(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
ListFollowing(userID, currentUserID uint, page, pageSize int) (*service.FollowListResult, error)
}

View File

@ -10,9 +10,10 @@ import (
"github.com/gin-gonic/gin"
)
// notifProvider MessageController 所需的通知服务接口ISP4 个方法)
// notifProvider MessageController 所需的通知服务接口ISP5 个方法)
type notifProvider interface {
List(userID uint, page, pageSize int) (*model.NotificationListResult, error)
ListByCategory(userID uint, category string, page, pageSize int) (*model.NotificationListResult, error)
CountUnread(userID uint) (int64, error)
MarkRead(id, userID uint) error
MarkAllRead(userID uint) error

View File

@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
)
// MessagesPage 消息中心页面需登录noindex
// MessagesPage 消息中心页面需登录noindex,支持分类 TAB
func (mc *MessageController) MessagesPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
@ -19,6 +19,8 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
uid := uidVal.(uint)
tab := c.DefaultQuery("tab", "all")
// 从 query 取分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@ -26,7 +28,15 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
}
pageSize := 20
result, err := mc.notifService.List(uid, page, pageSize)
var result *model.NotificationListResult
var err error
if tab == "all" {
result, err = mc.notifService.List(uid, page, pageSize)
} else {
result, err = mc.notifService.ListByCategory(uid, tab, page, pageSize)
}
if err != nil {
result = &model.NotificationListResult{Items: []model.Notification{}, Total: 0, Page: 1}
}
@ -50,5 +60,6 @@ func (mc *MessageController) MessagesPage(c *gin.Context) {
"UnreadCount": result.Unread,
"NotifyTypeNames": model.NotifyTypeNames,
"AuditTypeNames": model.AuditTypeNames,
"CurrentTab": tab,
}))
}

View File

@ -0,0 +1,14 @@
package model
import "time"
// DailyLikeSummary 每日点赞汇聚(用于聚合点赞通知)
type DailyLikeSummary struct {
ID uint `gorm:"primarykey" json:"id"`
AuthorUID uint `gorm:"uniqueIndex:idx_author_date;not null" json:"author_uid"` // 被点赞文章的作者
Date string `gorm:"type:varchar(10);uniqueIndex:idx_author_date;not null" json:"date"` // 日期 YYYY-MM-DDAsia/Shanghai
LikerUIDs string `gorm:"type:text" json:"liker_uids"` // 点赞者 UID 列表(追加式,逗号分隔)
Notified bool `gorm:"default:false" json:"notified"` // 是否已发送聚合通知
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -2,13 +2,15 @@ package model
// 消息/通知类型常量
const (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
NotifyLikeAggregated = "like_aggregated" // 每日聚合点赞通知
NotifyFollow = "follow" // 关注通知
)
// Notification 消息/通知模型
@ -26,13 +28,28 @@ type Notification struct {
// NotifyTypeNames 通知类型 → 中文名
var NotifyTypeNames = map[string]string{
NotifyAuditApproved: "资料审核",
NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyAuditApproved: "资料审核",
NotifyAuditRejected: "资料审核",
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
NotifyLikeAggregated: "点赞",
NotifyFollow: "关注",
}
// NotifyCategory 通知类型所属分类 TAB
var NotifyCategory = map[string]string{
NotifyAuditApproved: "system",
NotifyAuditRejected: "system",
NotifyPostApproved: "system",
NotifyPostRejected: "system",
NotifyLevelUp: "system",
NotifyComment: "mention",
NotifyCommentReply: "mention",
NotifyLikeAggregated: "like",
NotifyFollow: "follow",
}
// NotificationListResult 消息列表查询结果

View File

@ -19,6 +19,14 @@ type User struct {
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
Energy int `gorm:"default:0" json:"energy"` // 域能余额可为负数乘10存储
// 关注系统
FollowersCount int `gorm:"default:0" json:"followers_count"` // 粉丝数(冗余计数器)
FollowingCount int `gorm:"default:0" json:"following_count"` // 关注数(冗余计数器)
FollowListPublic bool `gorm:"default:true" json:"follow_list_public"` // 关注/粉丝列表是否公开
// 通知偏好 (JSON string)
NotifyPrefs string `gorm:"type:text;default:'{\"comment\":true,\"comment_reply\":true,\"like_aggregated\":true,\"follow\":true}'" json:"-"`
// 安全与审计
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
LastLoginIP string `gorm:"type:varchar(45);default:''" json:"-"` // 最后登录 IP

View File

@ -0,0 +1,14 @@
package model
import "time"
// UserFollow 关注关系
type UserFollow struct {
FollowerID uint `gorm:"primaryKey;not null" json:"follower_id"`
FolloweeID uint `gorm:"primaryKey;not null" json:"followee_id"`
CreatedAt time.Time `json:"created_at"`
// 联表查询填充(非数据库字段)
FollowerUsername string `gorm:"-:migration;<-:false;column:follower_username" json:"follower_username,omitempty"`
FolloweeUsername string `gorm:"-:migration;<-:false;column:followee_username" json:"followee_username,omitempty"`
}

View File

@ -0,0 +1,112 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"metazone.cc/metalab/internal/service"
"gorm.io/gorm"
)
// FollowRepo 关注数据访问
type FollowRepo struct {
db *gorm.DB
}
// NewFollowRepo 构造函数
func NewFollowRepo(db *gorm.DB) *FollowRepo {
return &FollowRepo{db: db}
}
// WithTx 基于事务连接创建 FollowRepo
func (r *FollowRepo) WithTx(tx *gorm.DB) service.FollowStore {
return &FollowRepo{db: tx}
}
// Create 创建关注
func (r *FollowRepo) Create(follow *model.UserFollow) error {
return r.db.Create(follow).Error
}
// Delete 取消关注
func (r *FollowRepo) Delete(followerID, followeeID uint) error {
return r.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.UserFollow{}).Error
}
// Exists 检查是否已关注
func (r *FollowRepo) Exists(followerID, followeeID uint) (bool, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).
Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Count(&count).Error
return count > 0, err
}
// CountFollowers 粉丝数
func (r *FollowRepo) CountFollowers(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&count).Error
return count, err
}
// CountFollowing 关注数
func (r *FollowRepo) CountFollowing(userID uint) (int64, error) {
var count int64
err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&count).Error
return count, err
}
// ListFollowers 粉丝列表分页JOIN users 获取用户名)
func (r *FollowRepo) ListFollowers(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("followee_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS follower_username").
Joins("INNER JOIN users u ON u.id = user_follows.follower_id").
Where("user_follows.followee_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// ListFollowing 关注列表分页JOIN users 获取用户名)
func (r *FollowRepo) ListFollowing(userID uint, offset, limit int) ([]model.UserFollow, int64, error) {
var total int64
if err := r.db.Model(&model.UserFollow{}).Where("follower_id = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.UserFollow
err := r.db.Table("user_follows").
Select("user_follows.*, u.username AS followee_username").
Joins("INNER JOIN users u ON u.id = user_follows.followee_id").
Where("user_follows.follower_id = ?", userID).
Order("user_follows.created_at DESC").
Offset(offset).Limit(limit).
Scan(&items).Error
return items, total, err
}
// IncrFollowersCount 原子增减粉丝数
func (r *FollowRepo) IncrFollowersCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("followers_count", gorm.Expr("followers_count + ?", delta)).Error
}
// IncrFollowingCount 原子增减关注数
func (r *FollowRepo) IncrFollowingCount(userID uint, delta int) error {
return r.db.Model(&model.User{}).
Where("id = ?", userID).
UpdateColumn("following_count", gorm.Expr("following_count + ?", delta)).Error
}
// GetFollowListPublic 查询用户关注列表公开性
func (r *FollowRepo) GetFollowListPublic(userID uint) (bool, error) {
var user model.User
err := r.db.Select("follow_list_public").First(&user, userID).Error
return user.FollowListPublic, err
}

View File

@ -60,3 +60,48 @@ func (r *NotificationRepo) MarkAllRead(userID uint) error {
Where("user_id = ? AND is_read = false", userID).
Update("is_read", true).Error
}
// ListByUserAndType 按类型分页查询用户消息
func (r *NotificationRepo) ListByUserAndType(userID uint, notifyType string, offset, limit int) ([]model.Notification, error) {
var list []model.Notification
err := r.db.Where("user_id = ? AND notify_type = ?", userID, notifyType).
Order("created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
return list, err
}
// CountByUserAndType 按类型统计用户消息数
func (r *NotificationRepo) CountByUserAndType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).Where("user_id = ? AND notify_type = ?", userID, notifyType).Count(&count).Error
return count, err
}
// CountUnreadByType 按类型统计用户未读消息数
func (r *NotificationRepo) CountUnreadByType(userID uint, notifyType string) (int64, error) {
var count int64
err := r.db.Model(&model.Notification{}).
Where("user_id = ? AND is_read = false AND notify_type = ?", userID, notifyType).
Count(&count).Error
return count, err
}
// GetUnnotifiedDailyLikeSummaries 获取未通知的每日点赞汇总
func (r *NotificationRepo) GetUnnotifiedDailyLikeSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkDailyLikeSummaryNotified 标记汇总已通知
func (r *NotificationRepo) MarkDailyLikeSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}
// GetUsernameByID 根据用户ID获取用户名
func (r *NotificationRepo) GetUsernameByID(userID uint) (string, error) {
var user model.User
err := r.db.Select("username").First(&user, userID).Error
return user.Username, err
}

View File

@ -75,3 +75,47 @@ func (r *ReactionRepo) IncrPostDislikes(postID uint, delta int) error {
Where("id = ?", postID).
UpdateColumn("dislikes_count", gorm.Expr("dislikes_count + ?", delta)).Error
}
// GetPostAuthorID 查询文章作者ID
func (r *ReactionRepo) GetPostAuthorID(postID uint) (uint, error) {
var post model.Post
err := r.db.Select("user_id").First(&post, postID).Error
return post.UserID, err
}
// UpsertDailyLikeSummary 追加或创建每日点赞汇总
func (r *ReactionRepo) UpsertDailyLikeSummary(authorUID uint, date string, likerUID string) error {
var existing model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND date = ?", authorUID, date).First(&existing).Error
if err == gorm.ErrRecordNotFound {
return r.db.Create(&model.DailyLikeSummary{
AuthorUID: authorUID,
Date: date,
LikerUIDs: likerUID,
Notified: false,
}).Error
}
if err != nil {
return err
}
// 追加 likerUID
newUids := existing.LikerUIDs
if newUids == "" {
newUids = likerUID
} else {
newUids = newUids + "," + likerUID
}
return r.db.Model(&existing).Update("liker_uids", newUids).Error
}
// GetUnnotifiedSummaries 查询未通知的每日点赞汇总
func (r *ReactionRepo) GetUnnotifiedSummaries(userID uint) ([]model.DailyLikeSummary, error) {
var summaries []model.DailyLikeSummary
err := r.db.Where("author_uid = ? AND notified = ?", userID, false).Find(&summaries).Error
return summaries, err
}
// MarkSummaryNotified 标记汇总已通知
func (r *ReactionRepo) MarkSummaryNotified(id uint) error {
return r.db.Model(&model.DailyLikeSummary{}).Where("id = ?", id).Update("notified", true).Error
}

View File

@ -131,4 +131,18 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
reactionAPI.POST("/like", d.reactionCtrl.ToggleLike)
reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike)
}
// --- 关注 API ---
// 公开(未登录返回 false
r.GET("/api/users/:uid/follow-status", d.followCtrl.GetStatus)
r.GET("/api/users/:uid/followers", d.followCtrl.Followers)
r.GET("/api/users/:uid/following", d.followCtrl.Following)
// 需登录:切换关注
followAPI := r.Group("/api/users/:uid")
followAPI.Use(d.authMdw.Required())
followAPI.Use(d.authMdw.BannedWriteGuard())
followAPI.Use(middleware.CSRF(cfg))
{
followAPI.POST("/follow", d.followCtrl.Toggle)
}
}

View File

@ -38,6 +38,8 @@ type dependencies struct {
commentService *service.CommentService
adminCommentCtrl *adminCtrl.AdminCommentController
reactionCtrl *controller.ReactionController
followCtrl *controller.FollowController
followPageCtrl *controller.FollowPageController
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -74,6 +74,13 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
reactionService := service.NewReactionService(db, reactionRepo)
reactionCtrl := controller.NewReactionController(reactionService)
// 关注系统
followRepo := repository.NewFollowRepo(db)
followService := service.NewFollowService(db, followRepo)
followService.SetNotifier(notificationService) // 关注通知
followCtrl := controller.NewFollowController(followService)
followPageCtrl := controller.NewFollowPageController(followService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
@ -96,5 +103,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
commentService: commentService,
adminCommentCtrl: adminCommentCtrl,
reactionCtrl: reactionCtrl,
followCtrl: followCtrl,
followPageCtrl: followPageCtrl,
}
}

View File

@ -45,6 +45,10 @@ func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
pages.GET("/space", d.spaceCtrl.MySpace)
pages.GET("/space/:uid", d.spaceCtrl.ShowSpace)
// 关注/粉丝列表页
pages.GET("/space/:uid/followers", d.followPageCtrl.FollowersPage)
pages.GET("/space/:uid/following", d.followPageCtrl.FollowingPage)
// 帖子页面
pages.GET("/posts", d.postCtrl.ListPage)
pages.GET("/posts/:id", d.postCtrl.ShowPage)

View 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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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
}