feat: 更新评论功能,优化代码格式,添加位置格式化功能
All checks were successful
Push to Helm Chart Repository / build (push) Successful in 52s

This commit is contained in:
2025-09-13 16:55:58 +08:00
parent 4da06b931f
commit 8be05cd9c2
4 changed files with 244 additions and 227 deletions

View File

@ -1,50 +1,50 @@
package dto package dto
type CommentDto struct { type CommentDto struct {
ID uint `json:"id"` ID uint `json:"id"`
TargetID uint `json:"target_id"` TargetID uint `json:"target_id"`
TargetType string `json:"target_type"` // 目标类型,如 "post", "page" TargetType string `json:"target_type"` // 目标类型,如 "post", "page"
Content string `json:"content"` Content string `json:"content"`
ReplyID uint `json:"reply_id"` // 回复的评论ID ReplyID uint `json:"reply_id"` // 回复的评论ID
Depth int `json:"depth"` // 评论的层级深度 Depth int `json:"depth"` // 评论的层级深度
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
User UserDto `json:"user"` // 评论的 User UserDto `json:"user"` // 评论的
ReplyCount uint64 `json:"reply_count"` // 回复数量 ReplyCount uint64 `json:"reply_count"` // 回复数量
LikeCount uint64 `json:"like_count"` // 点赞数量 LikeCount uint64 `json:"like_count"` // 点赞数量
IsLiked bool `json:"is_liked"` // 当前用户是否点赞 IsLiked bool `json:"is_liked"` // 当前用户是否点赞
IsPrivate bool `json:"is_private"` IsPrivate bool `json:"is_private"`
Location string `json:"location"` // 用户位置基于IP Location string `json:"location"` // 用户位置基于IP
OS string `json:"os"` // 用户操作系统基于User-Agent OS string `json:"os"` // 用户操作系统基于User-Agent
Browser string `json:"browser"` // 用户浏览器基于User-Agent Browser string `json:"browser"` // 用户浏览器基于User-Agent
ShowClientInfo bool `json:"show_client_info"` ShowClientInfo bool `json:"show_client_info"`
} }
type CreateCommentReq struct { type CreateCommentReq struct {
TargetID uint `json:"target_id" binding:"required"` // 目标ID TargetID uint `json:"target_id" binding:"required"` // 目标ID
TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page" TargetType string `json:"target_type" binding:"required"` // 目标类型,如 "post", "page"
Content string `json:"content" binding:"required"` // 评论内容 Content string `json:"content" binding:"required"` // 评论内容
ReplyID uint `json:"reply_id"` // 回复的评论ID ReplyID uint `json:"reply_id"` // 回复的评论ID
IsPrivate bool `json:"is_private"` // 是否私密评论默认false IsPrivate bool `json:"is_private"` // 是否私密评论默认false
RemoteAddr string `json:"remote_addr"` // 远程地址 RemoteAddr string `json:"remote_addr"` // 远程地址
UserAgent string `json:"user_agent"` // 用户代理 UserAgent string `json:"user_agent"` // 用户代理
ShowClientInfo bool `json:"show_client_info"` // 是否显示客户端信息 ShowClientInfo bool `json:"show_client_info"` // 是否显示客户端信息
} }
type UpdateCommentReq struct { type UpdateCommentReq struct {
CommentID uint `json:"comment_id" binding:"required"` // 评论ID CommentID uint `json:"comment_id" binding:"required"` // 评论ID
Content string `json:"content" binding:"required"` // 评论内容 Content string `json:"content" binding:"required"` // 评论内容
IsPrivate bool `json:"is_private"` // 是否私密 IsPrivate bool `json:"is_private"` // 是否私密
ShowClientInfo bool `json:"show_client_info"` // 是否显示客户端信息 ShowClientInfo bool `json:"show_client_info"` // 是否显示客户端信息
} }
type GetCommentListReq struct { type GetCommentListReq struct {
TargetID uint `json:"target_id" binding:"required"` TargetID uint `json:"target_id" binding:"required"`
TargetType string `json:"target_type" binding:"required"` TargetType string `json:"target_type" binding:"required"`
CommentID uint `json:"comment_id"` // 获取某条评论的所有子评论 CommentID uint `json:"comment_id"` // 获取某条评论的所有子评论
OrderBy string `json:"order_by"` // 排序方式 OrderBy string `json:"order_by"` // 排序方式
Page uint64 `json:"page"` // 页码 Page uint64 `json:"page"` // 页码
Size uint64 `json:"size"` Size uint64 `json:"size"`
Desc bool `json:"desc"` Desc bool `json:"desc"`
Depth int `json:"depth"` // 评论的层级深度 Depth int `json:"depth"` // 评论的层级深度
} }

View File

@ -1,188 +1,188 @@
package service package service
import ( import (
"context" "context"
"strconv" "strconv"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant" "github.com/snowykami/neo-blog/pkg/constant"
"github.com/snowykami/neo-blog/pkg/utils" "github.com/snowykami/neo-blog/pkg/utils"
"github.com/snowykami/neo-blog/internal/ctxutils" "github.com/snowykami/neo-blog/internal/ctxutils"
"github.com/snowykami/neo-blog/internal/dto" "github.com/snowykami/neo-blog/internal/dto"
"github.com/snowykami/neo-blog/internal/model" "github.com/snowykami/neo-blog/internal/model"
"github.com/snowykami/neo-blog/internal/repo" "github.com/snowykami/neo-blog/internal/repo"
"github.com/snowykami/neo-blog/pkg/errs" "github.com/snowykami/neo-blog/pkg/errs"
) )
type CommentService struct{} type CommentService struct{}
func NewCommentService() *CommentService { func NewCommentService() *CommentService {
return &CommentService{} return &CommentService{}
} }
func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) { func (cs *CommentService) CreateComment(ctx context.Context, req *dto.CreateCommentReq) (uint, error) {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return 0, errs.ErrUnauthorized return 0, errs.ErrUnauthorized
} }
if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok { if ok, err := cs.checkTargetExists(req.TargetID, req.TargetType); !ok {
if err != nil { if err != nil {
return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err) return 0, errs.New(errs.ErrBadRequest.Code, "target not found", err)
} }
return 0, errs.ErrBadRequest return 0, errs.ErrBadRequest
} }
comment := &model.Comment{ comment := &model.Comment{
Content: req.Content, Content: req.Content,
ReplyID: req.ReplyID, ReplyID: req.ReplyID,
TargetID: req.TargetID, TargetID: req.TargetID,
TargetType: req.TargetType, TargetType: req.TargetType,
UserID: currentUser.ID, UserID: currentUser.ID,
IsPrivate: req.IsPrivate, IsPrivate: req.IsPrivate,
RemoteAddr: req.RemoteAddr, RemoteAddr: req.RemoteAddr,
UserAgent: req.UserAgent, UserAgent: req.UserAgent,
ShowClientInfo: req.ShowClientInfo, ShowClientInfo: req.ShowClientInfo,
} }
commentID, err := repo.Comment.CreateComment(comment) commentID, err := repo.Comment.CreateComment(comment)
if err != nil { if err != nil {
return 0, err return 0, err
} }
return commentID, nil return commentID, nil
} }
func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error { func (cs *CommentService) UpdateComment(ctx context.Context, req *dto.UpdateCommentReq) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID) logrus.Infof("UpdateComment: currentUser ID %d, req.CommentID %d", currentUser.ID, req.CommentID)
comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID))) comment, err := repo.Comment.GetComment(strconv.Itoa(int(req.CommentID)))
if err != nil { if err != nil {
return err return err
} }
if currentUser.ID != comment.UserID { if currentUser.ID != comment.UserID {
return errs.ErrForbidden return errs.ErrForbidden
} }
comment.Content = req.Content comment.Content = req.Content
comment.IsPrivate = req.IsPrivate comment.IsPrivate = req.IsPrivate
comment.ShowClientInfo = req.ShowClientInfo comment.ShowClientInfo = req.ShowClientInfo
err = repo.Comment.UpdateComment(comment) err = repo.Comment.UpdateComment(comment)
if err != nil { if err != nil {
return err return err
} }
return nil return nil
} }
func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error { func (cs *CommentService) DeleteComment(ctx context.Context, commentID string) error {
currentUser, ok := ctxutils.GetCurrentUser(ctx) currentUser, ok := ctxutils.GetCurrentUser(ctx)
if !ok { if !ok {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
if commentID == "" { if commentID == "" {
return errs.ErrBadRequest return errs.ErrBadRequest
} }
comment, err := repo.Comment.GetComment(commentID) comment, err := repo.Comment.GetComment(commentID)
if err != nil { if err != nil {
return errs.New(errs.ErrNotFound.Code, "comment not found", err) return errs.New(errs.ErrNotFound.Code, "comment not found", err)
} }
if comment.UserID != currentUser.ID { if comment.UserID != currentUser.ID {
return errs.ErrForbidden return errs.ErrForbidden
} }
if err := repo.Comment.DeleteComment(commentID); err != nil { if err := repo.Comment.DeleteComment(commentID); err != nil {
return err return err
} }
return nil return nil
} }
func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) { func (cs *CommentService) GetComment(ctx context.Context, commentID string) (*dto.CommentDto, error) {
comment, err := repo.Comment.GetComment(commentID) comment, err := repo.Comment.GetComment(commentID)
if err != nil { if err != nil {
return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err) return nil, errs.New(errs.ErrNotFound.Code, "comment not found", err)
} }
currentUserID := uint(0) currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID currentUserID = currentUser.ID
} }
if comment.IsPrivate && currentUserID != comment.UserID { if comment.IsPrivate && currentUserID != comment.UserID {
return nil, errs.ErrForbidden return nil, errs.ErrForbidden
} }
commentDto := cs.toGetCommentDto(comment, currentUserID) commentDto := cs.toGetCommentDto(comment, currentUserID)
return &commentDto, err return &commentDto, err
} }
func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) { func (cs *CommentService) GetCommentList(ctx context.Context, req *dto.GetCommentListReq) ([]dto.CommentDto, error) {
currentUserID := uint(0) currentUserID := uint(0)
if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok { if currentUser, ok := ctxutils.GetCurrentUser(ctx); ok {
currentUserID = currentUser.ID currentUserID = currentUser.ID
} }
comments, err := repo.Comment.ListComments(currentUserID, req.TargetID, req.CommentID, 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 { if err != nil {
return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err) return nil, errs.New(errs.ErrInternalServer.Code, "failed to list comments", err)
} }
commentDtos := make([]dto.CommentDto, 0) commentDtos := make([]dto.CommentDto, 0)
for _, comment := range comments { for _, comment := range comments {
//replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID) //replyCount, _ := repo.Comment.CountReplyComments(currentUserID, comment.ID)
commentDto := cs.toGetCommentDto(&comment, currentUserID) commentDto := cs.toGetCommentDto(&comment, currentUserID)
commentDtos = append(commentDtos, commentDto) commentDtos = append(commentDtos, commentDto)
} }
return commentDtos, nil return commentDtos, nil
} }
func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto { func (cs *CommentService) toGetCommentDto(comment *model.Comment, currentUserID uint) dto.CommentDto {
isLiked := false isLiked := false
if currentUserID != 0 { if currentUserID != 0 {
isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment) isLiked, _ = repo.Like.IsLiked(currentUserID, comment.ID, constant.TargetTypeComment)
} }
ua := utils.ParseUA(comment.UserAgent) ua := utils.ParseUA(comment.UserAgent)
if !comment.ShowClientInfo { if !comment.ShowClientInfo {
comment.Location = "" comment.Location = ""
ua.OS = "" ua.OS = ""
ua.OSVersion = "" ua.OSVersion = ""
ua.Browser = "" ua.Browser = ""
ua.BrowserVer = "" ua.BrowserVer = ""
} }
return dto.CommentDto{ return dto.CommentDto{
ID: comment.ID, ID: comment.ID,
Content: comment.Content, Content: comment.Content,
TargetID: comment.TargetID, TargetID: comment.TargetID,
TargetType: comment.TargetType, TargetType: comment.TargetType,
ReplyID: comment.ReplyID, ReplyID: comment.ReplyID,
CreatedAt: comment.CreatedAt.String(), CreatedAt: comment.CreatedAt.String(),
UpdatedAt: comment.UpdatedAt.String(), UpdatedAt: comment.UpdatedAt.String(),
Depth: comment.Depth, Depth: comment.Depth,
User: comment.User.ToDto(), User: comment.User.ToDto(),
ReplyCount: comment.CommentCount, ReplyCount: comment.CommentCount,
LikeCount: comment.LikeCount, LikeCount: comment.LikeCount,
IsLiked: isLiked, IsLiked: isLiked,
IsPrivate: comment.IsPrivate, IsPrivate: comment.IsPrivate,
OS: ua.OS + " " + ua.OSVersion, OS: ua.OS + " " + ua.OSVersion,
Browser: ua.Browser + " " + ua.BrowserVer, Browser: ua.Browser + " " + ua.BrowserVer,
Location: comment.Location, Location: comment.Location,
ShowClientInfo: comment.ShowClientInfo, ShowClientInfo: comment.ShowClientInfo,
} }
} }
func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) { func (cs *CommentService) checkTargetExists(targetID uint, targetType string) (bool, error) {
switch targetType { switch targetType {
case constant.TargetTypePost: case constant.TargetTypePost:
if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil { if _, err := repo.Post.GetPostByID(strconv.Itoa(int(targetID))); err != nil {
return false, errs.New(errs.ErrNotFound.Code, "post not found", err) return false, errs.New(errs.ErrNotFound.Code, "post not found", err)
} }
default: default:
return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil) return false, errs.New(errs.ErrBadRequest.Code, "invalid target type", nil)
} }
return true, nil return true, nil
} }

View File

@ -1,53 +1,54 @@
package constant package constant
const ( const (
CaptchaTypeDisable = "disable" // 禁用验证码 CaptchaTypeDisable = "disable" // 禁用验证码
CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码 CaptchaTypeHCaptcha = "hcaptcha" // HCaptcha验证码
CaptchaTypeTurnstile = "turnstile" // Turnstile验证码 CaptchaTypeTurnstile = "turnstile" // Turnstile验证码
CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码 CaptchaTypeReCaptcha = "recaptcha" // ReCaptcha验证码
ContextKeyUserID = "user_id" // 上下文键用户ID ContextKeyUserID = "user_id" // 上下文键用户ID
ModeDev = "dev" ModeDev = "dev"
ModeProd = "prod" ModeProd = "prod"
RoleUser = "user" RoleUser = "user"
RoleAdmin = "admin" RoleAdmin = "admin"
EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL EnvKeyBaseUrl = "BASE_URL" // 环境变量基础URL
EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者 EnvKeyCaptchaProvider = "CAPTCHA_PROVIDER" // captcha提供者
EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥 EnvKeyCaptchaSecreteKey = "CAPTCHA_SECRET_KEY" // captcha站点密钥
EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url EnvKeyCaptchaUrl = "CAPTCHA_URL" // 某些自托管的captcha的url
EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key EnvKeyCaptchaSiteKey = "CAPTCHA_SITE_KEY" // captcha密钥key
EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别 EnvKeyLocationFormat = "LOCATION_FORMAT" // 环境变量:时区格式
EnvKeyMode = "MODE" // 环境变量:运行模式 EnvKeyLogLevel = "LOG_LEVEL" // 环境变量:日志级别
EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥 EnvKeyMode = "MODE" // 环境变量:运行模式
EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐 EnvKeyJwtSecrete = "JWT_SECRET" // 环境变量:JWT密钥
EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期 EnvKeyPasswordSalt = "PASSWORD_SALT" // 环境变量:密码盐
EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度 EnvKeyTokenDuration = "TOKEN_DURATION" // 环境变量:令牌有效期
EnvKeyTokenDurationDefault = 300 // Token有效时长 EnvKeyMaxReplyDepth = "MAX_REPLY_DEPTH" // 环境变量:最大回复深度
EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长 EnvKeyTokenDurationDefault = 300 // Token有效时长
EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期 EnvKeyRefreshTokenDurationDefault = 604800 // refresh token有效时长
EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期 EnvKeyRefreshTokenDuration = "REFRESH_TOKEN_DURATION" // 环境变量:刷新令牌有效期
KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码 EnvKeyRefreshTokenDurationWithRemember = "REFRESH_TOKEN_DURATION_WITH_REMEMBER" // 环境变量:记住我刷新令牌有效期
KVKeyOidcState = "oidc_state:" // KV存储OIDC状态 KVKeyEmailVerificationCode = "email_verification_code:" // KV存储邮箱验证码
ApiSuffix = "/api/v1" // API版本前缀 KVKeyOidcState = "oidc_state:" // KV存储OIDC状态
OidcUri = "/user/oidc/login" // OIDC登录URI ApiSuffix = "/api/v1" // API版本前缀
OidcProviderTypeMisskey = "misskey" // OIDC提供者类型Misskey OidcUri = "/user/oidc/login" // OIDC登录URI
OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型GitHub OidcProviderTypeMisskey = "misskey" // OIDC提供者类型Misskey
DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl OidcProviderTypeOauth2 = "oauth2" // OIDC提供者类型GitHub
TargetTypePost = "post" DefaultBaseUrl = "http://localhost:3000" // 默认BaseUrl
TargetTypeComment = "comment" TargetTypePost = "post"
OrderByCreatedAt = "created_at" // 按创建时间排序 TargetTypeComment = "comment"
OrderByUpdatedAt = "updated_at" // 按更新时间排序 OrderByCreatedAt = "created_at" // 按创建时间排序
OrderByLikeCount = "like_count" // 按点赞数排序 OrderByUpdatedAt = "updated_at" // 按更新时间排序
OrderByCommentCount = "comment_count" // 按评论数排序 OrderByLikeCount = "like_count" // 按点赞数排序
OrderByViewCount = "view_count" // 按浏览量排序 OrderByCommentCount = "comment_count" // 按评论数排序
OrderByHeat = "heat" OrderByViewCount = "view_count" // 按浏览量排序
MaxReplyDepthDefault = 3 // 默认最大回复深度 OrderByHeat = "heat"
HeatFactorViewWeight = 1 // 热度因子:浏览量权重 MaxReplyDepthDefault = 3 // 默认最大回复深度
HeatFactorLikeWeight = 5 // 热度因子:点赞权重 HeatFactorViewWeight = 1 // 热度因子:浏览量权重
HeatFactorCommentWeight = 10 // 热度因子:评论权重 HeatFactorLikeWeight = 5 // 热度因子:点赞权重
PageLimitDefault = 20 // 默认分页大小 HeatFactorCommentWeight = 10 // 热度因子:评论权重
PageLimitDefault = 20 // 默认分页大小
) )
var ( var (
OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式 OrderByEnumPost = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByLikeCount, OrderByCommentCount, OrderByViewCount, OrderByHeat} // 帖子可用的排序方式
OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式 OrderByEnumComment = []string{OrderByCreatedAt, OrderByUpdatedAt, OrderByCommentCount} // 评论可用的排序方式
) )

View File

@ -1,9 +1,12 @@
package utils package utils
import ( import (
"bytes"
"fmt" "fmt"
"text/template"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/snowykami/neo-blog/pkg/constant"
) )
type IPData struct { type IPData struct {
@ -56,5 +59,18 @@ func GetLocationString(ip string) string {
if ipInfo == nil { if ipInfo == nil {
return "" return ""
} }
return fmt.Sprintf("%s %s %s %s", ipInfo.Country, ipInfo.Province, ipInfo.City, ipInfo.ISP)
tpl := Env.Get(constant.EnvKeyLocationFormat, "{{.Country}} {{.Province}} {{.City}} {{.ISP}}")
t, err := template.New("location").Parse(tpl)
if err != nil {
logrus.Error(err)
return ""
}
var buf bytes.Buffer
if err := t.Execute(&buf, ipInfo); err != nil {
logrus.Error(err)
return ""
}
return buf.String()
} }