feat: implement advanced comment features including reply and like functionality
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 13s

- Added support for nested comments with reply functionality.
- Implemented like/unlike feature for comments and posts.
- Enhanced comment DTO to include reply count, like count, and like status.
- Updated comment and like services to handle new functionalities.
- Created new API endpoints for toggling likes and listing comments.
- Improved UI components for comments to support replies and likes with animations.
- Added localization for new comment-related messages.
- Introduced a TODO list for future enhancements in the comment module.
This commit is contained in:
2025-09-09 00:24:25 +08:00
parent 382132f550
commit dd7641bf6e
28 changed files with 422 additions and 101 deletions

View File

@ -107,6 +107,16 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
resps.BadRequest(c, "无效的 target_id")
return
}
commentIDStr := c.Query("comment_id")
var commentID uint
if commentIDStr != "" {
commentIDInt, err := strconv.Atoi(commentIDStr)
if err != nil {
resps.BadRequest(c, "无效的 comment_id")
return
}
commentID = uint(commentIDInt)
}
req := dto.GetCommentListReq{
Desc: pagination.Desc,
OrderBy: pagination.OrderBy,
@ -115,6 +125,7 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC
Depth: depthInt,
TargetID: uint(targetID),
TargetType: c.Query("target_type"),
CommentID: commentID,
}
resp, err := cc.service.GetCommentList(ctx, &req)
if err != nil {

View File

@ -2,15 +2,39 @@ package v1
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
)
type LikeController struct{}
type LikeController struct {
service *service.LikeService
}
func NewLikeController() *LikeController {
return &LikeController{}
return &LikeController{
service: service.NewLikeService(),
}
}
func (lc *LikeController) ToggleLike(ctx context.Context, c *app.RequestContext) {
// Implementation for creating a like
var toggleLikeReq dto.ToggleLikeReq
if err := c.BindAndValidate(&toggleLikeReq); err != nil {
logrus.Error(err)
resps.BadRequest(c, resps.ErrParamInvalid)
return
}
liked, err := lc.service.ToggleLike(ctx, toggleLikeReq.TargetID, toggleLikeReq.TargetType)
if err != nil {
serviceErr := errs.AsServiceError(err)
logrus.Error(serviceErr.Error())
resps.Custom(c, serviceErr.Code, serviceErr.Message, nil)
return
}
resps.Ok(c, resps.Success, utils.H{"status": liked})
}

View File

@ -3,6 +3,8 @@ package v1
import (
"context"
"fmt"
"strconv"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/common/utils"
"github.com/snowykami/neo-blog/internal/ctxutils"
@ -10,7 +12,6 @@ import (
"github.com/snowykami/neo-blog/internal/service"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/resps"
"strconv"
)
type UserController struct {

View File

@ -9,7 +9,10 @@ type CommentDto struct {
Depth int `json:"depth"` // 评论的层级深度
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
User UserDto `json:"user"` // 评论的
User UserDto `json:"user"` // 评论的
ReplyCount int64 `json:"reply_count"` // 回复数量
LikeCount uint64 `json:"like_count"` // 点赞数量
IsLiked bool `json:"is_liked"` // 当前用户是否点赞
}
type CreateCommentReq struct {
@ -29,8 +32,9 @@ type UpdateCommentReq struct {
type GetCommentListReq struct {
TargetID uint `json:"target_id" binding:"required"`
TargetType string `json:"target_type" binding:"required"`
OrderBy string `json:"order_by"` // 排序方式
Page uint64 `json:"page"` // 页码
CommentID uint `json:"comment_id"` // 获取某条评论的所有子评论
OrderBy string `json:"order_by"` // 排序方式
Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"`
Desc bool `json:"desc"`
Depth int `json:"depth"` // 评论的层级深度

View File

@ -1 +1,6 @@
package dto
type ToggleLikeReq struct {
TargetID uint `json:"target_id" binding:"required"`
TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "comment"
}

View File

@ -2,6 +2,7 @@ package model
import (
"fmt"
"github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm"
)
@ -32,10 +33,10 @@ func (l *Like) AfterDelete(tx *gorm.DB) (err error) {
switch l.TargetType {
case constant.TargetTypePost:
return tx.Model(&Post{}).Where("id = ?", l.TargetID).
UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error
UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
case constant.TargetTypeComment:
return tx.Model(&Comment{}).Where("id = ?", l.TargetID).
UpdateColumn("like_count", gorm.Expr("GREATEST(like_count - ?, 0)", 1)).Error
UpdateColumn("like_count", gorm.Expr("like_count - ?", 1)).Error
default:
return fmt.Errorf("不支持的目标类型: %s", l.TargetType)
}

View File

@ -9,6 +9,7 @@ import (
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"github.com/snowykami/neo-blog/pkg/utils"
"gorm.io/gorm"
)
@ -92,16 +93,15 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) error {
}
depth = parentComment.Depth + 1
}
if depth > utils.Env.GetAsInt(constant.EnvKeyMaxReplyDepth, constant.MaxReplyDepthDefault) {
return errs.New(http.StatusBadRequest, "exceeded maximum reply depth", nil)
}
comment.Depth = depth
if err := tx.Create(comment).Error; err != nil {
return err
}
return nil
})
return err
}
@ -172,7 +172,7 @@ func (cr *CommentRepo) GetComment(commentID string) (*model.Comment, error) {
return &comment, nil
}
func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetType string, page, size uint64, orderBy string, desc bool, depth int) ([]model.Comment, error) {
func (cr *CommentRepo) ListComments(currentUserID, targetID, commentID uint, targetType string, page, size uint64, orderBy string, desc bool, depth int) ([]model.Comment, error) {
if !slices.Contains(constant.OrderByEnumComment, orderBy) {
return nil, errs.New(http.StatusBadRequest, "invalid order_by parameter", nil)
}
@ -189,13 +189,17 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp
query := GetDB().Model(&model.Comment{}).Preload("User")
if commentID > 0 {
query = query.Where("reply_id = ?", commentID)
}
if currentUserID > 0 {
query = query.Where("(is_private = ? OR (is_private = ? AND (user_id = ? OR user_id = ?)))", false, true, currentUserID, masterID)
} else {
query = query.Where("is_private = ?", false)
}
if depth > 0 {
if depth >= 0 {
query = query.Where("target_id = ? AND target_type = ? AND depth = ?", targetID, targetType, depth)
} else {
query = query.Where("target_id = ? AND target_type = ?", targetID, targetType)
@ -208,3 +212,11 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp
return items, nil
}
func (cr *CommentRepo) CountReplyComments(commentID uint) (int64, error) {
var count int64
if err := GetDB().Model(&model.Comment{}).Where("reply_id = ?", commentID).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@ -2,6 +2,8 @@ package repo
import (
"errors"
"github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant"
"gorm.io/gorm"
@ -11,24 +13,28 @@ type likeRepo struct{}
var Like = &likeRepo{}
func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) (bool, error) {
err := l.checkTargetType(targetType)
if err != nil {
return err
return false, err
}
return GetDB().Transaction(func(tx *gorm.DB) error {
// 判断是否已点赞
var finalStatus bool
err = GetDB().Transaction(func(tx *gorm.DB) error {
isLiked, err := l.IsLiked(userID, targetID, targetType)
if err != nil {
logrus.Error(err)
return err
}
if isLiked {
// 已点赞,执行取消点赞逻辑
if err := tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).Delete(&model.Like{}).Error; err != nil {
if err :=
tx.Where("target_type = ? AND target_id = ? AND user_id = ?", targetType, targetID, userID).
Delete(&model.Like{TargetType: targetType, TargetID: targetID, UserID: userID}).
Error; err != nil {
logrus.Error(err)
return err
}
finalStatus = false
} else {
// 未点赞,执行新增点赞逻辑
like := &model.Like{
TargetType: targetType,
TargetID: targetID,
@ -37,6 +43,7 @@ func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
if err := tx.Create(like).Error; err != nil {
return err
}
finalStatus = true
}
// 重新计算点赞数量
var count int64
@ -58,6 +65,7 @@ func (l *likeRepo) ToggleLike(userID, targetID uint, targetType string) error {
}
return nil
})
return finalStatus, err
}
// IsLiked 检查是否点赞

View File

@ -1,11 +1,12 @@
package repo
import (
"net/http"
"slices"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/errs"
"net/http"
"slices"
)
type postRepo struct{}
@ -73,13 +74,13 @@ func (p *postRepo) ListPosts(currentUserID uint, keywords []string, page, size u
return items, nil
}
func (p *postRepo) ToggleLikePost(postID uint, userID uint) error {
func (p *postRepo) ToggleLikePost(postID uint, userID uint) (bool, error) {
if postID == 0 || userID == 0 {
return errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil)
return false, errs.New(http.StatusBadRequest, "invalid post ID or user ID", nil)
}
err := Like.ToggleLike(userID, postID, constant.TargetTypePost)
liked, err := Like.ToggleLike(userID, postID, constant.TargetTypePost)
if err != nil {
return err
return false, err
}
return nil
return liked, nil
}

View File

@ -130,7 +130,7 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
currentUserID = currentUser.ID
}
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc, req.Depth)
if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
}
@ -138,15 +138,25 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen
commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments {
replyCount, _ := repo.Comment.CountReplyComments(comment.ID)
isLiked := false
if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
}
commentDto := dto.CommentDto{
ID: comment.ID,
Content: comment.Content,
TargetID: comment.TargetID,
TargetType: comment.TargetType,
ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth,
User: comment.User.ToDto(),
ReplyCount: replyCount,
LikeCount: comment.LikeCount,
IsLiked: isLiked,
}
commentDtos = append(commentDtos, commentDto)
}

View File

@ -1 +1,23 @@
package service
import (
"context"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
)
type LikeService struct{}
func NewLikeService() *LikeService {
return &LikeService{}
}
func (ls *LikeService) ToggleLike(ctx context.Context, targetID uint, targetType string) (bool, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return false, errs.ErrUnauthorized
}
return repo.Like.ToggleLike(currentUser.ID, targetID, targetType)
}

View File

@ -2,12 +2,13 @@ package service
import (
"context"
"strconv"
"github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs"
"strconv"
)
type PostService struct{}
@ -126,27 +127,28 @@ func (p *PostService) ListPosts(ctx context.Context, req *dto.ListPostReq) ([]*d
return postDtos, nil
}
func (p *PostService) ToggleLikePost(ctx context.Context, id string) error {
func (p *PostService) ToggleLikePost(ctx context.Context, id string) (bool, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok {
return errs.ErrUnauthorized
return false, errs.ErrUnauthorized
}
if id == "" {
return errs.ErrBadRequest
return false, errs.ErrBadRequest
}
post, err := repo.Post.GetPostByID(id)
if err != nil {
return errs.New(errs.ErrNotFound.Code, "post not found", err)
return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
}
if post.UserID == currentUser.ID {
return errs.ErrForbidden
return false, errs.ErrForbidden
}
idInt, err := strconv.ParseUint(id, 10, 64)
if err != nil {
return errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
return false, errs.New(errs.ErrBadRequest.Code, "invalid post ID", err)
}
if err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID); err != nil {
return errs.ErrInternalServer
liked, err := repo.Post.ToggleLikePost(uint(idInt), currentUser.ID)
if err != nil {
return false, errs.ErrInternalServer
}
return nil
return liked, nil
}