diff --git a/cmd/server/main.go b/cmd/server/main.go index 91e6f75..daf9772 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{}); 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{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 8ee26a2..4e55719 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -103,3 +103,11 @@ type commentUseCase interface { ListReplies(rootID uint) ([]model.Comment, error) SearchUsers(keyword string) ([]model.UserSearchResult, error) } + +// reactionUseCase ReactionController 对 ReactionService 的最小依赖(ISP:4 个方法) +type reactionUseCase interface { + ToggleLike(userID, postID uint) (bool, error) + ToggleDislike(userID, postID uint) (bool, error) + GetReaction(userID, postID uint) (service.ReactionType, error) + GetPostLikesCount(postID uint) (int, error) +} diff --git a/internal/controller/reaction_controller.go b/internal/controller/reaction_controller.go new file mode 100644 index 0000000..d3ee87d --- /dev/null +++ b/internal/controller/reaction_controller.go @@ -0,0 +1,108 @@ +package controller + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/service" + + "github.com/gin-gonic/gin" +) + +// ReactionController 赞/踩 API 控制器 +type ReactionController struct { + reactionSvc reactionUseCase +} + +// NewReactionController 构造函数 +func NewReactionController(reactionSvc reactionUseCase) *ReactionController { + return &ReactionController{reactionSvc: reactionSvc} +} + +// ToggleLike 切换点赞 POST /api/posts/:id/like +func (rc *ReactionController) ToggleLike(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章ID") + return + } + + isLiked, err := rc.reactionSvc.ToggleLike(uid, uint(postID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + // 获取最新点赞数 + likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID)) + + common.Ok(c, gin.H{ + "liked": isLiked, + "likes_count": likesCount, + }) +} + +// ToggleDislike 切换踩 POST /api/posts/:id/dislike +func (rc *ReactionController) ToggleDislike(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章ID") + return + } + + isDisliked, err := rc.reactionSvc.ToggleDislike(uid, uint(postID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + common.Ok(c, gin.H{ + "disliked": isDisliked, + }) +} + +// GetReaction 查询当前用户对文章的反应状态 GET /api/posts/:id/reaction +func (rc *ReactionController) GetReaction(c *gin.Context) { + postID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的文章ID") + return + } + + // 未登录用户返回 none + 公开计数 + uid, _, ok := common.GetGinUser(c) + if !ok { + likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID)) + common.Ok(c, gin.H{ + "reaction": string(service.ReactionNone), + "likes_count": likesCount, + }) + return + } + + reaction, err := rc.reactionSvc.GetReaction(uid, uint(postID)) + if err != nil { + common.Error(c, http.StatusInternalServerError, "查询失败") + return + } + + likesCount, _ := rc.reactionSvc.GetPostLikesCount(uint(postID)) + + common.Ok(c, gin.H{ + "reaction": string(reaction), + "likes_count": likesCount, + }) +} diff --git a/internal/model/post.go b/internal/model/post.go index 61b0e0d..b8f2bb8 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -22,6 +22,8 @@ type Post struct { AllowComment bool `gorm:"default:true" json:"allow_comment"` CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器) TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数) + LikesCount int `gorm:"default:0" json:"likes_count"` // 点赞数(冗余计数器) + DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 踩数(冗余计数器,仅后台可见) DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/internal/model/post_dislike.go b/internal/model/post_dislike.go new file mode 100644 index 0000000..b5678c8 --- /dev/null +++ b/internal/model/post_dislike.go @@ -0,0 +1,10 @@ +package model + +import "time" + +// PostDislike 踩记录(仅后台可见) +type PostDislike struct { + UserID uint `gorm:"primaryKey;not null" json:"user_id"` + PostID uint `gorm:"primaryKey;not null" json:"post_id"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/model/post_like.go b/internal/model/post_like.go new file mode 100644 index 0000000..68f1f7c --- /dev/null +++ b/internal/model/post_like.go @@ -0,0 +1,10 @@ +package model + +import "time" + +// PostLike 点赞记录 +type PostLike struct { + UserID uint `gorm:"primaryKey;not null" json:"user_id"` + PostID uint `gorm:"primaryKey;not null" json:"post_id"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/repository/reaction_repo.go b/internal/repository/reaction_repo.go new file mode 100644 index 0000000..1401c45 --- /dev/null +++ b/internal/repository/reaction_repo.go @@ -0,0 +1,77 @@ +package repository + +import ( + "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" + + "gorm.io/gorm" +) + +// ReactionRepo 赞/踩数据访问 +type ReactionRepo struct { + db *gorm.DB +} + +// NewReactionRepo 构造函数 +func NewReactionRepo(db *gorm.DB) *ReactionRepo { + return &ReactionRepo{db: db} +} + +// WithTx 基于给定事务连接创建新的 ReactionRepo +func (r *ReactionRepo) WithTx(tx *gorm.DB) service.ReactionStore { + return &ReactionRepo{db: tx} +} + +// FindLike 查找点赞记录 +func (r *ReactionRepo) FindLike(userID, postID uint) (*model.PostLike, error) { + var like model.PostLike + err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&like).Error + if err != nil { + return nil, err + } + return &like, nil +} + +// CreateLike 创建点赞记录 +func (r *ReactionRepo) CreateLike(like *model.PostLike) error { + return r.db.Create(like).Error +} + +// DeleteLike 删除点赞记录 +func (r *ReactionRepo) DeleteLike(userID, postID uint) error { + return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostLike{}).Error +} + +// FindDislike 查找踩记录 +func (r *ReactionRepo) FindDislike(userID, postID uint) (*model.PostDislike, error) { + var dislike model.PostDislike + err := r.db.Where("user_id = ? AND post_id = ?", userID, postID).First(&dislike).Error + if err != nil { + return nil, err + } + return &dislike, nil +} + +// CreateDislike 创建踩记录 +func (r *ReactionRepo) CreateDislike(dislike *model.PostDislike) error { + return r.db.Create(dislike).Error +} + +// DeleteDislike 删除踩记录 +func (r *ReactionRepo) DeleteDislike(userID, postID uint) error { + return r.db.Where("user_id = ? AND post_id = ?", userID, postID).Delete(&model.PostDislike{}).Error +} + +// IncrPostLikes 原子增加文章点赞数 +func (r *ReactionRepo) IncrPostLikes(postID uint, delta int) error { + return r.db.Model(&model.Post{}). + Where("id = ?", postID). + UpdateColumn("likes_count", gorm.Expr("likes_count + ?", delta)).Error +} + +// IncrPostDislikes 原子增加文章踩数 +func (r *ReactionRepo) IncrPostDislikes(postID uint, delta int) error { + return r.db.Model(&model.Post{}). + Where("id = ?", postID). + UpdateColumn("dislikes_count", gorm.Expr("dislikes_count + ?", delta)).Error +} diff --git a/internal/router/api.go b/internal/router/api.go index 25a48ec..a0b9bd7 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -118,4 +118,17 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers) commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage) } + + // --- 赞/踩 API --- + // 公开:查询反应状态(未登录返回 none + 点赞数) + r.GET("/api/posts/:id/reaction", d.reactionCtrl.GetReaction) + // 需登录:切换赞/踩 + reactionAPI := r.Group("/api/posts/:id") + reactionAPI.Use(d.authMdw.Required()) + reactionAPI.Use(d.authMdw.BannedWriteGuard()) + reactionAPI.Use(middleware.CSRF(cfg)) + { + reactionAPI.POST("/like", d.reactionCtrl.ToggleLike) + reactionAPI.POST("/dislike", d.reactionCtrl.ToggleDislike) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 9878fc3..55a5088 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -37,6 +37,7 @@ type dependencies struct { commentCtrl *controller.CommentController commentService *service.CommentService adminCommentCtrl *adminCtrl.AdminCommentController + reactionCtrl *controller.ReactionController } 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 c330ab7..d4e84dd 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -69,6 +69,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting // 后台评论管理 adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService) + // 赞/踩系统 + reactionRepo := repository.NewReactionRepo(db) + reactionService := service.NewReactionService(db, reactionRepo) + reactionCtrl := controller.NewReactionController(reactionService) + // 用户空间 spaceService := service.NewSpaceService(userRepo, postRepo) spaceCtrl := controller.NewSpaceController(spaceService) @@ -90,5 +95,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting commentCtrl: commentCtrl, commentService: commentService, adminCommentCtrl: adminCommentCtrl, + reactionCtrl: reactionCtrl, } } diff --git a/internal/service/reaction_service.go b/internal/service/reaction_service.go new file mode 100644 index 0000000..b3c5599 --- /dev/null +++ b/internal/service/reaction_service.go @@ -0,0 +1,155 @@ +package service + +import ( + "fmt" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" +) + +// ReactionType 反应类型 +type ReactionType string + +const ( + ReactionNone ReactionType = "none" + ReactionLiked ReactionType = "liked" + ReactionDisliked ReactionType = "disliked" +) + +// ReactionService 赞/踩业务逻辑 +type ReactionService struct { + db *gorm.DB + repo ReactionStore +} + +// NewReactionService 构造函数 +func NewReactionService(db *gorm.DB, repo ReactionStore) *ReactionService { + return &ReactionService{db: db, repo: repo} +} + +// ToggleLike 切换点赞(含踩互斥逻辑) +// 返回:当前是否已点赞 +func (s *ReactionService) ToggleLike(userID, postID uint) (bool, error) { + var isLiked bool + + err := s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 查当前状态 + _, likeErr := txRepo.FindLike(userID, postID) + hasLike := likeErr == nil + + _, dislikeErr := txRepo.FindDislike(userID, postID) + hasDislike := dislikeErr == nil + + if hasLike { + // 已赞 → 取消赞 + if err := txRepo.DeleteLike(userID, postID); err != nil { + return fmt.Errorf("取消点赞失败: %w", err) + } + if err := txRepo.IncrPostLikes(postID, -1); err != nil { + return fmt.Errorf("更新点赞计数失败: %w", err) + } + isLiked = false + } else { + // 无赞 → 加赞(若已有踩,先移除踩) + if hasDislike { + if err := txRepo.DeleteDislike(userID, postID); err != nil { + return fmt.Errorf("移除踩记录失败: %w", err) + } + if err := txRepo.IncrPostDislikes(postID, -1); err != nil { + return fmt.Errorf("更新踩计数失败: %w", err) + } + } + + if err := txRepo.CreateLike(&model.PostLike{UserID: userID, PostID: postID}); err != nil { + return fmt.Errorf("创建点赞记录失败: %w", err) + } + if err := txRepo.IncrPostLikes(postID, 1); err != nil { + return fmt.Errorf("更新点赞计数失败: %w", err) + } + isLiked = true + } + return nil + }) + + return isLiked, err +} + +// ToggleDislike 切换踩(含赞互斥逻辑) +// 返回:当前是否已踩 +func (s *ReactionService) ToggleDislike(userID, postID uint) (bool, error) { + var isDisliked bool + + err := s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + + // 查当前状态 + _, likeErr := txRepo.FindLike(userID, postID) + hasLike := likeErr == nil + + _, dislikeErr := txRepo.FindDislike(userID, postID) + hasDislike := dislikeErr == nil + + if hasDislike { + // 已踩 → 取消踩 + if err := txRepo.DeleteDislike(userID, postID); err != nil { + return fmt.Errorf("取消踩失败: %w", err) + } + if err := txRepo.IncrPostDislikes(postID, -1); err != nil { + return fmt.Errorf("更新踩计数失败: %w", err) + } + isDisliked = false + } else { + // 无踩 → 加踩(若已有赞,先移除赞) + if hasLike { + if err := txRepo.DeleteLike(userID, postID); err != nil { + return fmt.Errorf("移除点赞记录失败: %w", err) + } + if err := txRepo.IncrPostLikes(postID, -1); err != nil { + return fmt.Errorf("更新点赞计数失败: %w", err) + } + } + + if err := txRepo.CreateDislike(&model.PostDislike{UserID: userID, PostID: postID}); err != nil { + return fmt.Errorf("创建踩记录失败: %w", err) + } + if err := txRepo.IncrPostDislikes(postID, 1); err != nil { + return fmt.Errorf("更新踩计数失败: %w", err) + } + isDisliked = true + } + return nil + }) + + return isDisliked, err +} + +// GetReaction 查询用户对文章的反应状态 +func (s *ReactionService) GetReaction(userID, postID uint) (ReactionType, error) { + _, likeErr := s.repo.FindLike(userID, postID) + if likeErr == nil { + return ReactionLiked, nil + } + + _, dislikeErr := s.repo.FindDislike(userID, postID) + if dislikeErr == nil { + return ReactionDisliked, nil + } + + return ReactionNone, nil +} + +// GetPostLikesCount 查询文章点赞数(公开展示) +func (s *ReactionService) GetPostLikesCount(postID uint) (int, error) { + var post model.Post + if err := s.db.Select("likes_count").First(&post, postID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return 0, common.ErrPostNotFound + } + return 0, fmt.Errorf("查询文章失败: %w", err) + } + return post.LikesCount, nil +} diff --git a/internal/service/repository.go b/internal/service/repository.go index d56498b..a19e03c 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -153,3 +153,16 @@ type commentStore interface { type commentNotifier interface { Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error } + +// ReactionStore ReactionService 所需的仓储接口(ISP) +type ReactionStore interface { + FindLike(userID, postID uint) (*model.PostLike, error) + CreateLike(like *model.PostLike) error + DeleteLike(userID, postID uint) error + FindDislike(userID, postID uint) (*model.PostDislike, error) + CreateDislike(dislike *model.PostDislike) error + DeleteDislike(userID, postID uint) error + IncrPostLikes(postID uint, delta int) error + IncrPostDislikes(postID uint, delta int) error + WithTx(tx *gorm.DB) ReactionStore +} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 6d8ce05..3696afe 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -31,6 +31,17 @@
+ +