diff --git a/internal/controller/v1/comment.go b/internal/controller/v1/comment.go index 2eb8150..b7caecf 100644 --- a/internal/controller/v1/comment.go +++ b/internal/controller/v1/comment.go @@ -89,6 +89,11 @@ func (cc *CommentController) GetComment(ctx context.Context, c *app.RequestConte } func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestContext) { + depth := c.Query("depth") + depthInt, err := strconv.Atoi(depth) + if err != nil || depthInt < 0 { + depthInt = 1 + } pagination := ctxutils.GetPaginationParams(c) if pagination.OrderBy == "" { pagination.OrderBy = constant.OrderByUpdatedAt @@ -107,6 +112,7 @@ func (cc *CommentController) GetCommentList(ctx context.Context, c *app.RequestC OrderBy: pagination.OrderBy, Page: pagination.Page, Size: pagination.Size, + Depth: depthInt, TargetID: uint(targetID), TargetType: c.Query("target_type"), } diff --git a/internal/dto/comment.go b/internal/dto/comment.go index a4dd7be..6d6fbb8 100644 --- a/internal/dto/comment.go +++ b/internal/dto/comment.go @@ -17,19 +17,21 @@ type CreateCommentReq struct { TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page" Content string `json:"content" binding:"required"` // 评论内容 ReplyID uint `json:"reply_id"` // 回复的评论ID - IsPrivate bool `json:"is_private" binding:"required"` // 是否私密 + IsPrivate bool `json:"is_private"` // 是否私密评论,默认false } type UpdateCommentReq struct { CommentID uint `json:"comment_id" binding:"required"` // 评论ID Content string `json:"content" binding:"required"` // 评论内容 + IsPrivate bool `json:"is_private"` // 是否私密 } type GetCommentListReq struct { - TargetID uint `json:"target_id" binding:"required"` + TargetID uint `json:"target_id" binding:"required"` TargetType string `json:"target_type" binding:"required"` - OrderBy string `json:"order_by"` // 排序方式 - Page uint64 `json:"page"` // 页码 - Size uint64 `json:"size"` - Desc bool `json:"desc"` -} \ No newline at end of file + OrderBy string `json:"order_by"` // 排序方式 + Page uint64 `json:"page"` // 页码 + Size uint64 `json:"size"` + Desc bool `json:"desc"` + Depth int `json:"depth"` // 评论的层级深度 +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 9ca05a9..a6db2e6 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "context" "github.com/cloudwego/hertz/pkg/app" + "github.com/sirupsen/logrus" "github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/repo" "github.com/snowykami/neo-blog/pkg/constant" @@ -15,7 +16,7 @@ import ( func UseAuth(block bool) app.HandlerFunc { return func(ctx context.Context, c *app.RequestContext) { // For cookie - tokenFromCookie := string(c.Cookie("tokenFromCookie")) + tokenFromCookie := string(c.Cookie("token")) tokenFromHeader := strings.TrimPrefix(string(c.GetHeader("Authorization")), "Bearer ") refreshToken := string(c.Cookie("refresh_token")) @@ -25,8 +26,10 @@ func UseAuth(block bool) app.HandlerFunc { if err == nil && tokenClaims != nil { ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) c.Next(ctx) + logrus.Debugf("UseAuth: tokenFromCookie authenticated successfully, userID: %d", tokenClaims.UserID) return } + logrus.Debugf("UseAuth: tokenFromCookie authentication failed, error: %v", err) } // tokenFromCookie 认证失败,尝试用 Bearer tokenFromHeader 认证 if tokenFromHeader != "" { @@ -34,8 +37,10 @@ func UseAuth(block bool) app.HandlerFunc { if err == nil && tokenClaims != nil { ctx = context.WithValue(ctx, constant.ContextKeyUserID, tokenClaims.UserID) c.Next(ctx) + logrus.Debugf("UseAuth: tokenFromHeader authenticated successfully, userID: %d", tokenClaims.UserID) return } + logrus.Debugf("UseAuth: tokenFromHeader authentication failed, error: %v", err) } // tokenFromCookie 失效 使用 refresh tokenFromCookie 重新签发和鉴权 refreshTokenClaims, err := utils.Jwt.ParseJsonWebTokenWithoutState(refreshToken) @@ -56,19 +61,19 @@ func UseAuth(block bool) app.HandlerFunc { } else { resps.InternalServerError(c, resps.ErrInternalServerError) } - c.Next(ctx) + logrus.Debugf("UseAuth: refreshToken authenticated successfully, userID: %d", refreshTokenClaims.UserID) return } } // 所有认证方式都失败 if block { - // 若需要阻断,返回未授权错误并中止请求 + logrus.Debug("UseAuth: all authentication methods failed, blocking request") resps.Unauthorized(c, resps.ErrUnauthorized) c.Abort() } else { - // 若不需要阻断,继续请求但不设置用户ID + logrus.Debug("UseAuth: all authentication methods failed, blocking request") c.Next(ctx) } } diff --git a/internal/model/comment.go b/internal/model/comment.go index 192f81a..ad4b157 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -10,7 +10,7 @@ type Comment struct { TargetType string `gorm:"index"` // 目标类型,如 "post", "page" ReplyID uint `gorm:"index"` // 回复的评论ID Content string `gorm:"type:text"` // 评论内容 - Depth int `gorm:"default:0"` // 评论的层级深度 + Depth int `gorm:"default:0"` // 评论的层级深度,从0开始计数 IsPrivate bool `gorm:"default:false"` // 是否为私密评论,私密评论只有评论者和被评论对象所有者可见 LikeCount uint64 CommentCount uint64 diff --git a/internal/repo/comment.go b/internal/repo/comment.go index 8ccb45f..9d5d3ba 100644 --- a/internal/repo/comment.go +++ b/internal/repo/comment.go @@ -48,7 +48,6 @@ func (cr *CommentRepo) isCircularReference(tx *gorm.DB, commentID, parentID uint return false, nil } - // 递归删除子评论的辅助函数 func (cr *CommentRepo) deleteChildren(tx *gorm.DB, parentID uint) error { var children []*model.Comment @@ -99,7 +98,7 @@ func (cr *CommentRepo) CreateComment(comment *model.Comment) error { if err := tx.Create(comment).Error; err != nil { return err } - + return nil }) @@ -173,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) ([]model.Comment, error) { +func (cr *CommentRepo) ListComments(currentUserID uint, targetID 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) } @@ -196,9 +195,13 @@ func (cr *CommentRepo) ListComments(currentUserID uint, targetID uint, targetTyp query = query.Where("is_private = ?", false) } - query = query.Where("target_id = ? AND target_type = ?", targetID, targetType) - + 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) + } items, _, err := PaginateQuery[model.Comment](query, page, size, orderBy, desc) + if err != nil { return nil, err } diff --git a/internal/router/apiv1/comment.go b/internal/router/apiv1/comment.go index fa6bb46..13909c8 100644 --- a/internal/router/apiv1/comment.go +++ b/internal/router/apiv1/comment.go @@ -8,14 +8,14 @@ import ( func registerCommentRoutes(group *route.RouterGroup) { commentController := v1.NewCommentController() - commentGroup := group.Group("/comments").Use(middleware.UseAuth(true)) - commentGroupWithoutAuth := group.Group("/comments").Use(middleware.UseAuth(false)) + commentGroup := group.Group("/comment").Use(middleware.UseAuth(true)) + commentGroupWithoutAuth := group.Group("/comment").Use(middleware.UseAuth(false)) { commentGroup.POST("/c", commentController.CreateComment) commentGroup.PUT("/c/:id", commentController.UpdateComment) commentGroup.DELETE("/c/:id", commentController.DeleteComment) commentGroup.PUT("/c/:id/react", commentController.ReactComment) // 暂时先不写 commentGroupWithoutAuth.GET("/c/:id", commentController.GetComment) - commentGroupWithoutAuth.GET("/c/list", commentController.GetCommentList) + commentGroupWithoutAuth.GET("/list", commentController.GetCommentList) } } diff --git a/internal/service/comment.go b/internal/service/comment.go index 2dbeb97..09d95b0 100644 --- a/internal/service/comment.go +++ b/internal/service/comment.go @@ -2,6 +2,7 @@ package service import ( "context" + "github.com/snowykami/neo-blog/pkg/constant" "strconv" "github.com/snowykami/neo-blog/internal/ctxutils" @@ -23,6 +24,13 @@ func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateComm return errs.ErrUnauthorized } + if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { + if err != nil { + return errs.New(errs.ErrBadRequest.Code, "target not found", err) + } + return errs.ErrBadRequest + } + comment := &model.Comment{ Content: req.Content, ReplyID: req.ReplyID, @@ -57,6 +65,7 @@ func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateComm } comment.Content = req.Content + comment.IsPrivate = req.IsPrivate err = repo.Comment.UpdateComment(comment) @@ -117,7 +126,7 @@ func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dt func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) { currentUser, _ := ctxutils.GetCurrentUser(ctx) - comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, req.TargetType, req.Page, req.Size, req.OrderBy, req.Desc) + comments, err := repo.Comment.ListComments(currentUser.ID, req.TargetID, 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) } @@ -139,3 +148,15 @@ func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommen } return commentDtos, nil } + +func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) { + switch targetType { + case constant.TargetTypePost: + if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil { + return false, errs.New(errs.ErrNotFound.Code, "post not found", err) + } + default: + return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil) + } + return true, nil +} diff --git a/internal/service/user.go b/internal/service/user.go index 32e42a9..0a533d7 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -352,12 +352,12 @@ func (s *UserService) UpdateUser(req *dto.UpdateUserReq) (*dto.UpdateUserResp, e } func (s *UserService) generate2Token(userID uint) (string, string, error) { - token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault)*int(time.Second))) + token := utils.Jwt.NewClaims(userID, "", false, time.Duration(utils.Env.GetAsInt(constant.EnvKeyTokenDuration, constant.EnvKeyTokenDurationDefault))*time.Second) tokenString, err := token.ToString() if err != nil { return "", "", errs.ErrInternalServer } - refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault)*int(time.Second))) + refreshToken := utils.Jwt.NewClaims(userID, utils.Strings.GenerateRandomString(64), true, time.Duration(utils.Env.GetAsInt(constant.EnvKeyRefreshTokenDuration, constant.EnvKeyRefreshTokenDurationDefault))*time.Second) refreshTokenString, err := refreshToken.ToString() if err != nil { return "", "", errs.ErrInternalServer diff --git a/web/src/api/comment.ts b/web/src/api/comment.ts new file mode 100644 index 0000000..7f7a3f5 --- /dev/null +++ b/web/src/api/comment.ts @@ -0,0 +1,41 @@ +import axiosClient from './client' +import { CreateCommentRequest, UpdateCommentRequest, Comment } from '@/models/comment' +import type { PaginationParams } from '@/models/common' +import { OrderBy } from '@/models/common' +import type { BaseResponse } from '@/models/resp' + + +export async function createComment( + data: CreateCommentRequest, +): Promise> { + const res = await axiosClient.post>('/comment/c', data) + return res.data +} + +export async function updateComment( + data: UpdateCommentRequest, +): Promise> { + const res = await axiosClient.put>(`/comment/c/${data.id}`, data) + return res.data +} + +export async function deleteComment(id: number): Promise { + await axiosClient.delete(`/comment/c/${id}`) +} + +export async function listComments( + targetType: 'post' | 'page', + targetId: number, + pagination: PaginationParams = { orderBy: OrderBy.CreatedAt, desc: false, page: 1, size: 10 }, + depth: number = 1 +): Promise> { + const res = await axiosClient.get>(`/comment/list`, { + params: { + targetType, + targetId, + ...pagination, + depth + } + }) + return res.data +} \ No newline at end of file diff --git a/web/src/api/post.ts b/web/src/api/post.ts index 69d2f8b..e406cf6 100644 --- a/web/src/api/post.ts +++ b/web/src/api/post.ts @@ -1,14 +1,8 @@ import type { Post } from '@/models/post' import type { BaseResponse } from '@/models/resp' import axiosClient from './client' +import type { ListPostsParams } from '@/models/post' -interface ListPostsParams { - page?: number - size?: number - orderBy?: string - desc?: boolean - keywords?: string -} export async function getPostById(id: string, token: string=""): Promise { try { diff --git a/web/src/api/user.ts b/web/src/api/user.ts index 23da67b..f5cb649 100644 --- a/web/src/api/user.ts +++ b/web/src/api/user.ts @@ -1,23 +1,8 @@ import type { OidcConfig } from '@/models/oidc-config' import type { BaseResponse } from '@/models/resp' -import type { User } from '@/models/user' +import type { LoginRequest, RegisterRequest, User } from '@/models/user' import axiosClient from './client' -export interface LoginRequest { - username: string - password: string - rememberMe?: boolean // 可以轻松添加新字段 - captcha?: string -} - -export interface RegisterRequest { - username: string - password: string - nickname: string - email: string - verificationCode?: string -} - export async function userLogin( data: LoginRequest, ): Promise> { diff --git a/web/src/components/blog-comment/blog-comment.tsx b/web/src/components/blog-comment/blog-comment.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/web/src/components/blog-post/blog-post.tsx b/web/src/components/blog-post/blog-post.tsx index 9f38f52..be5ca65 100644 --- a/web/src/components/blog-post/blog-post.tsx +++ b/web/src/components/blog-post/blog-post.tsx @@ -4,7 +4,7 @@ import { Calendar, Clock, FileText, Flame, Heart, MessageCircle, PenLine, Square import { RenderMarkdown } from "@/components/common/markdown"; import { isMobileByUA } from "@/utils/server/device"; import { calculateReadingTime } from "@/utils/common/post"; -import Link from "next/link"; +import CommentSection from "@/components/comment"; function PostMeta({ post }: { post: Post }) { return ( @@ -75,9 +75,6 @@ async function PostHeader({ post }: { post: Post }) { {post.isOriginal && ( 原创 - - 查看 - )} {(post.labels || []).map(label => ( @@ -141,6 +138,7 @@ async function BlogPost({ post }: { post: Post }) { {/* */} + ); } diff --git a/web/src/components/comment/comment-input.tsx b/web/src/components/comment/comment-input.tsx new file mode 100644 index 0000000..9627069 --- /dev/null +++ b/web/src/components/comment/comment-input.tsx @@ -0,0 +1,25 @@ +"use client"; +import { Textarea } from "@/components/ui/textarea" +import Gravatar from "@/components/common/gravatar" + +export function CommentInput() { + return ( +
+
+ {/* Avatar */} +
+ +
+ {/* Input Area */} +
+