diff --git a/cmd/server/main.go b/cmd/server/main.go index daf9772..83d8f29 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}, &model.CommunityFund{}, &model.FundLog{}, &model.PostLike{}, &model.PostDislike{}, &model.UserFollow{}, &model.DailyLikeSummary{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/controller/follow_controller.go b/internal/controller/follow_controller.go new file mode 100644 index 0000000..708a659 --- /dev/null +++ b/internal/controller/follow_controller.go @@ -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, + })) +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 4e55719..9f33f29 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -111,3 +111,11 @@ type reactionUseCase interface { GetReaction(userID, postID uint) (service.ReactionType, error) GetPostLikesCount(postID uint) (int, error) } + +// followUseCase FollowController 对 FollowService 的最小依赖(ISP:4 个方法) +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) +} diff --git a/internal/controller/message_controller.go b/internal/controller/message_controller.go index ef6520b..10bc184 100644 --- a/internal/controller/message_controller.go +++ b/internal/controller/message_controller.go @@ -10,9 +10,10 @@ import ( "github.com/gin-gonic/gin" ) -// notifProvider MessageController 所需的通知服务接口(ISP:4 个方法) +// notifProvider MessageController 所需的通知服务接口(ISP:5 个方法) 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 diff --git a/internal/controller/message_page_controller.go b/internal/controller/message_page_controller.go index b6f9c8b..fff99e5 100644 --- a/internal/controller/message_page_controller.go +++ b/internal/controller/message_page_controller.go @@ -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, })) } diff --git a/internal/model/daily_like_summary.go b/internal/model/daily_like_summary.go new file mode 100644 index 0000000..66437a2 --- /dev/null +++ b/internal/model/daily_like_summary.go @@ -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-DD(Asia/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"` +} diff --git a/internal/model/notification.go b/internal/model/notification.go index b3da1f4..827dc2e 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -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 消息列表查询结果 diff --git a/internal/model/user.go b/internal/model/user.go index d4459a0..d443f37 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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 diff --git a/internal/model/user_follow.go b/internal/model/user_follow.go new file mode 100644 index 0000000..19b109f --- /dev/null +++ b/internal/model/user_follow.go @@ -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"` +} diff --git a/internal/repository/follow_repo.go b/internal/repository/follow_repo.go new file mode 100644 index 0000000..5532975 --- /dev/null +++ b/internal/repository/follow_repo.go @@ -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 +} diff --git a/internal/repository/notification_repo.go b/internal/repository/notification_repo.go index 4dac368..1111e96 100644 --- a/internal/repository/notification_repo.go +++ b/internal/repository/notification_repo.go @@ -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 +} diff --git a/internal/repository/reaction_repo.go b/internal/repository/reaction_repo.go index 1401c45..01334cc 100644 --- a/internal/repository/reaction_repo.go +++ b/internal/repository/reaction_repo.go @@ -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 +} diff --git a/internal/router/api.go b/internal/router/api.go index a0b9bd7..6671d0d 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -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) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 55a5088..fe56011 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index d4e84dd..e69cf5c 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 1cb5acd..61040dd 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -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) diff --git a/internal/service/follow_service.go b/internal/service/follow_service.go new file mode 100644 index 0000000..29325b3 --- /dev/null +++ b/internal/service/follow_service.go @@ -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 +} diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 828be0b..bf1b467 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -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 +} diff --git a/internal/service/reaction_service.go b/internal/service/reaction_service.go index b3c5599..ca5bffd 100644 --- a/internal/service/reaction_service.go +++ b/internal/service/reaction_service.go @@ -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) { diff --git a/internal/service/repository.go b/internal/service/repository.go index a19e03c..387b9ee 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -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 +} diff --git a/templates/MetaLab-2026/html/follow/followers.html b/templates/MetaLab-2026/html/follow/followers.html new file mode 100644 index 0000000..370ef05 --- /dev/null +++ b/templates/MetaLab-2026/html/follow/followers.html @@ -0,0 +1,47 @@ +{{template "layout/header.html" .}} + + + +{{template "layout/nav.html" .}} + +
+

粉丝列表

+ + {{if not .Result.Accessible}} +
+

该用户设置了关注列表不公开

+
+ {{else if eq (len .Result.Items) 0}} +
+

暂无粉丝

+
+ {{else}} + {{range .Result.Items}} +
+ +
+ {{end}} + + {{if gt .TotalPages 1}} + + {{end}} + {{end}} +
+ +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/follow/following.html b/templates/MetaLab-2026/html/follow/following.html new file mode 100644 index 0000000..840d4f5 --- /dev/null +++ b/templates/MetaLab-2026/html/follow/following.html @@ -0,0 +1,47 @@ +{{template "layout/header.html" .}} + + + +{{template "layout/nav.html" .}} + +
+

关注列表

+ + {{if not .Result.Accessible}} +
+

该用户设置了关注列表不公开

+
+ {{else if eq (len .Result.Items) 0}} +
+

暂未关注任何人

+
+ {{else}} + {{range .Result.Items}} +
+ +
+ {{end}} + + {{if gt .TotalPages 1}} + + {{end}} + {{end}} +
+ +{{template "layout/footer.html" .}} + + + diff --git a/templates/MetaLab-2026/html/messages/index.html b/templates/MetaLab-2026/html/messages/index.html index 240d136..0348e8f 100644 --- a/templates/MetaLab-2026/html/messages/index.html +++ b/templates/MetaLab-2026/html/messages/index.html @@ -10,13 +10,29 @@

消息中心

@@ -78,13 +94,13 @@ {{if .TotalPages}}
{{if .HasPrev}} - 上一页 + 上一页 {{else}} 上一页 {{end}} {{.Page}} / {{.TotalPages}} {{if .HasNext}} - 下一页 + 下一页 {{else}} 下一页 {{end}} diff --git a/templates/MetaLab-2026/html/space/index.html b/templates/MetaLab-2026/html/space/index.html index 1b96807..43bcb45 100644 --- a/templates/MetaLab-2026/html/space/index.html +++ b/templates/MetaLab-2026/html/space/index.html @@ -30,6 +30,15 @@ · 加入于 {{.SpaceUser.CreatedAt.Format "2006-01-02"}}
+ + {{if not .IsOwnSpace}} +
+ +
+ {{end}} @@ -89,5 +98,75 @@ {{template "layout/footer.html" .}} +{{if not .IsOwnSpace}} + +{{end}} diff --git a/templates/MetaLab-2026/static/css/follow.css b/templates/MetaLab-2026/static/css/follow.css new file mode 100644 index 0000000..e29f963 --- /dev/null +++ b/templates/MetaLab-2026/static/css/follow.css @@ -0,0 +1,142 @@ +/* 关注按钮 */ +.follow-btn-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.follow-btn { + display: inline-flex; + align-items: center; + gap: .35rem; + padding: .4rem 1rem; + border: 1.5px solid var(--color-accent); + border-radius: 6px; + font-size: .85rem; + font-weight: 600; + cursor: pointer; + transition: all .15s ease; + background: transparent; + color: var(--color-accent); +} + +.follow-btn:hover { + background: var(--color-accent); + color: #fff; +} + +.follow-btn.following { + background: var(--color-accent); + color: #fff; +} + +.follow-btn.following:hover { + background: var(--color-danger, #e74c3c); + border-color: var(--color-danger, #e74c3c); +} + +.follow-btn.mutual { + background: var(--color-accent); + color: #fff; +} + +.follow-btn.mutual:hover { + background: var(--color-danger, #e74c3c); + border-color: var(--color-danger, #e74c3c); +} + +.follow-btn.waiting { + opacity: .6; + pointer-events: none; +} + +/* 关注/粉丝链接 */ +.follow-links { + display: flex; + gap: 1rem; + margin-top: .35rem; +} + +.follow-links a { + color: var(--color-secondary); + font-size: .85rem; + text-decoration: none; + transition: color .15s; +} + +.follow-links a:hover { + color: var(--color-accent); +} + +.follow-links strong { + color: var(--color-text); + font-weight: 600; +} + +/* 用户列表(粉丝/关注页) */ +.follow-list-page { + max-width: 700px; + margin: 2rem auto; + padding: 0 1rem; +} + +.follow-list-page h1 { + font-size: 1.4rem; + margin-bottom: 1.5rem; +} + +.follow-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: .8rem 0; + border-bottom: 1px solid var(--color-border, #e5e7eb); +} + +.follow-list-item:last-child { + border-bottom: none; +} + +.follow-list-user { + display: flex; + align-items: center; + gap: .75rem; + text-decoration: none; + color: var(--color-text); +} + +.follow-list-user:hover { + color: var(--color-accent); +} + +.follow-list-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--color-accent); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1rem; + flex-shrink: 0; +} + +.follow-list-name { + font-weight: 500; +} + +.follow-list-empty { + text-align: center; + color: var(--color-secondary); + padding: 3rem 0; +} + +/* 隐私提示 */ +.follow-list-private { + text-align: center; + color: var(--color-secondary); + padding: 3rem 0; +} diff --git a/templates/MetaLab-2026/static/css/messages.css b/templates/MetaLab-2026/static/css/messages.css index 390887e..7a32691 100644 --- a/templates/MetaLab-2026/static/css/messages.css +++ b/templates/MetaLab-2026/static/css/messages.css @@ -59,6 +59,8 @@ body { color: var(--color-secondary); font-weight: 500; transition: all .15s ease; + text-decoration: none; + cursor: pointer; } .msg-sidebar-item:hover {