feat: 实现赞/踩系统(P2)

- 新增 PostLike/PostDislike 模型,复合主键 (user_id, post_id)
- Post 表新增 LikesCount/DislikesCount 冗余计数器
- Repository 层:reaction_repo.go,含 WithTx 事务支持
- Service 层:reaction_service.go,赞踩互斥逻辑(6 态切换),事务包裹
- Controller 层:reaction_controller.go,3 个 API(ToggleLike/ToggleDislike/GetReaction)
- 接口定义:controller/interfaces.go 新增 reactionUseCase,service/repository.go 新增 ReactionStore
- 路由注册 + 依赖注入 + AutoMigrate
- 前端:文章详情页赞踩按钮 + 状态查询 + 401 跳转登录
- 样式:posts.css 新增赞踩按钮样式
This commit is contained in:
2026-06-01 15:48:18 +08:00
parent 4815060839
commit 2b47380ac5
14 changed files with 557 additions and 2 deletions

View File

@ -103,3 +103,11 @@ type commentUseCase interface {
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string) ([]model.UserSearchResult, error)
}
// reactionUseCase ReactionController 对 ReactionService 的最小依赖ISP4 个方法)
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)
}

View File

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

View File

@ -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"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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