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:
@ -27,7 +27,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("连接数据库失败: %v", err)
|
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)
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -103,3 +103,11 @@ type commentUseCase interface {
|
|||||||
ListReplies(rootID uint) ([]model.Comment, error)
|
ListReplies(rootID uint) ([]model.Comment, error)
|
||||||
SearchUsers(keyword string) ([]model.UserSearchResult, 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)
|
||||||
|
}
|
||||||
|
|||||||
108
internal/controller/reaction_controller.go
Normal file
108
internal/controller/reaction_controller.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -22,6 +22,8 @@ type Post struct {
|
|||||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||||
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
|
||||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
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:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
10
internal/model/post_dislike.go
Normal file
10
internal/model/post_dislike.go
Normal 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"`
|
||||||
|
}
|
||||||
10
internal/model/post_like.go
Normal file
10
internal/model/post_like.go
Normal 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"`
|
||||||
|
}
|
||||||
77
internal/repository/reaction_repo.go
Normal file
77
internal/repository/reaction_repo.go
Normal 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
|
||||||
|
}
|
||||||
@ -118,4 +118,17 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
|||||||
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
|
||||||
commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ type dependencies struct {
|
|||||||
commentCtrl *controller.CommentController
|
commentCtrl *controller.CommentController
|
||||||
commentService *service.CommentService
|
commentService *service.CommentService
|
||||||
adminCommentCtrl *adminCtrl.AdminCommentController
|
adminCommentCtrl *adminCtrl.AdminCommentController
|
||||||
|
reactionCtrl *controller.ReactionController
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||||
|
|||||||
@ -69,6 +69,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
// 后台评论管理
|
// 后台评论管理
|
||||||
adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService)
|
adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService)
|
||||||
|
|
||||||
|
// 赞/踩系统
|
||||||
|
reactionRepo := repository.NewReactionRepo(db)
|
||||||
|
reactionService := service.NewReactionService(db, reactionRepo)
|
||||||
|
reactionCtrl := controller.NewReactionController(reactionService)
|
||||||
|
|
||||||
// 用户空间
|
// 用户空间
|
||||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||||
spaceCtrl := controller.NewSpaceController(spaceService)
|
spaceCtrl := controller.NewSpaceController(spaceService)
|
||||||
@ -90,5 +95,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
|||||||
commentCtrl: commentCtrl,
|
commentCtrl: commentCtrl,
|
||||||
commentService: commentService,
|
commentService: commentService,
|
||||||
adminCommentCtrl: adminCommentCtrl,
|
adminCommentCtrl: adminCommentCtrl,
|
||||||
|
reactionCtrl: reactionCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
internal/service/reaction_service.go
Normal file
155
internal/service/reaction_service.go
Normal 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
|
||||||
|
}
|
||||||
@ -153,3 +153,16 @@ type commentStore interface {
|
|||||||
type commentNotifier interface {
|
type commentNotifier interface {
|
||||||
Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,17 @@
|
|||||||
<div class="post-detail-body" id="postContent"></div>
|
<div class="post-detail-body" id="postContent"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- 赞/踩按钮 -->
|
||||||
|
<div class="post-reactions" id="postReactions" data-post-id="{{.Post.ID}}">
|
||||||
|
<button class="reaction-btn reaction-like-btn" id="likeBtn" title="点赞">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3H14zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>
|
||||||
|
<span class="reaction-like-count" id="likesCount">{{.Post.LikesCount}}</span>
|
||||||
|
</button>
|
||||||
|
<button class="reaction-btn reaction-dislike-btn" id="dislikeBtn" title="踩">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3H10zM17 2h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{if .IsLoggedIn}}
|
{{if .IsLoggedIn}}
|
||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
|
{{if and $.Post (or (eq $.Post.UserID $.UID) $.CanAccessAdmin) (ne $.Post.Status "pending") (not $.Post.IsLocked)}}
|
||||||
@ -253,6 +264,82 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ========== 赞/踩系统 ==========
|
||||||
|
(function() {
|
||||||
|
var container = document.getElementById('postReactions');
|
||||||
|
var likeBtn = document.getElementById('likeBtn');
|
||||||
|
var dislikeBtn = document.getElementById('dislikeBtn');
|
||||||
|
var likesCountEl = document.getElementById('likesCount');
|
||||||
|
if (!container || !likeBtn || !dislikeBtn) return;
|
||||||
|
|
||||||
|
var postId = container.dataset.postId;
|
||||||
|
var csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
|
||||||
|
|
||||||
|
// 页面加载时获取当前反应状态
|
||||||
|
fetch('/api/posts/' + postId + '/reaction')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
if (!d.success || !d.data) return;
|
||||||
|
updateUI(d.data.reaction, d.data.likes_count);
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
|
function updateUI(reaction, count) {
|
||||||
|
likeBtn.classList.remove('active');
|
||||||
|
dislikeBtn.classList.remove('active');
|
||||||
|
if (reaction === 'liked') likeBtn.classList.add('active');
|
||||||
|
if (reaction === 'disliked') dislikeBtn.classList.add('active');
|
||||||
|
if (typeof count === 'number') likesCountEl.textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
likeBtn.addEventListener('click', function() {
|
||||||
|
likeBtn.disabled = true;
|
||||||
|
fetch('/api/posts/' + postId + '/like', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 401) { window.location.href = '/login?redirect=/posts/' + postId; return null; }
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(d) {
|
||||||
|
likeBtn.disabled = false;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.success) {
|
||||||
|
updateUI(d.data.liked ? 'liked' : 'none', d.data.likes_count);
|
||||||
|
} else {
|
||||||
|
alert(d.message || '操作失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { likeBtn.disabled = false; });
|
||||||
|
});
|
||||||
|
|
||||||
|
dislikeBtn.addEventListener('click', function() {
|
||||||
|
dislikeBtn.disabled = true;
|
||||||
|
fetch('/api/posts/' + postId + '/dislike', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 401) { window.location.href = '/login?redirect=/posts/' + postId; return null; }
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(d) {
|
||||||
|
dislikeBtn.disabled = false;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.success) {
|
||||||
|
updateUI(d.data.disliked ? 'disliked' : 'none');
|
||||||
|
} else {
|
||||||
|
alert(d.message || '操作失败');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() { dislikeBtn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ========== 评论系统 ==========
|
// ========== 评论系统 ==========
|
||||||
(function() {
|
(function() {
|
||||||
|
|||||||
@ -197,7 +197,72 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 404 ========== */
|
/* ========== 赞/踩按钮 ========== */
|
||||||
|
.post-reactions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn:hover {
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
color: #6366f1;
|
||||||
|
background: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-like-btn {
|
||||||
|
border-color: #6366f1;
|
||||||
|
color: #6366f1;
|
||||||
|
background: #f0eeff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-like-btn svg {
|
||||||
|
fill: #6366f1;
|
||||||
|
stroke: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-dislike-btn {
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #f87171;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active.reaction-dislike-btn svg {
|
||||||
|
fill: #f87171;
|
||||||
|
stroke: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-like-count {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-btn.active .reaction-like-count {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
.error-404 {
|
.error-404 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 100px 0;
|
padding: 100px 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user