Add comprehensive security improvements across the application: - **IP-based login protection**: Implement IP ban system tracking login failures, auto-banning after threshold exceeded - **Ownership verification**: Add userID parameter to Delete/Update operations for posts and comments to prevent unauthorized modifications - **SSRF protection**: Add URL and resolved host validation for image URLs in chat and OpenAI client - **SQL injection prevention**: Add EscapeLikeWildcard utility to escape special characters in LIKE queries - **HTTP security**: Configure server timeouts and add security headers middleware - **Rate limiting**: Refactor to support configurable duration and per-endpoint rate limits for auth routes - **Error handling**: Standardize error responses using HandleError and proper error types
392 lines
11 KiB
Go
392 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
apperrors "with_you/internal/errors"
|
||
"with_you/internal/cache"
|
||
"with_you/internal/dto"
|
||
"with_you/internal/model"
|
||
"with_you/internal/pkg/cursor"
|
||
"with_you/internal/pkg/hook"
|
||
"with_you/internal/repository"
|
||
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
type CommentService struct {
|
||
commentRepo repository.CommentRepository
|
||
postRepo repository.PostRepository
|
||
systemMessageService SystemMessageService
|
||
cache cache.Cache
|
||
postAIService *PostAIService
|
||
logService *LogService
|
||
hookManager *hook.Manager
|
||
}
|
||
|
||
func NewCommentService(commentRepo repository.CommentRepository, postRepo repository.PostRepository, systemMessageService SystemMessageService, postAIService *PostAIService, cacheBackend cache.Cache, hookManager *hook.Manager, logService *LogService) *CommentService {
|
||
return &CommentService{
|
||
commentRepo: commentRepo,
|
||
postRepo: postRepo,
|
||
systemMessageService: systemMessageService,
|
||
cache: cacheBackend,
|
||
postAIService: postAIService,
|
||
logService: logService,
|
||
hookManager: hookManager,
|
||
}
|
||
}
|
||
|
||
// Create 创建评论
|
||
func (s *CommentService) Create(ctx context.Context, postID, userID, content string, segments model.MessageSegments, parentID *string, images string, imageURLs []string) (*model.Comment, error) {
|
||
if s.postAIService != nil {
|
||
// 采用异步审核,前端先立即返回
|
||
}
|
||
|
||
// 获取帖子信息用于发送通知
|
||
post, err := s.postRepo.GetByID(postID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
comment := &model.Comment{
|
||
PostID: postID,
|
||
UserID: userID,
|
||
Content: content,
|
||
Segments: segments,
|
||
ParentID: parentID,
|
||
Images: images,
|
||
Status: model.CommentStatusPending,
|
||
}
|
||
|
||
// 如果有父评论,设置根评论ID
|
||
var parentUserID string
|
||
if parentID != nil {
|
||
parent, err := s.commentRepo.GetByID(*parentID)
|
||
if err == nil && parent != nil {
|
||
if parent.RootID != nil {
|
||
comment.RootID = parent.RootID
|
||
} else {
|
||
comment.RootID = parentID
|
||
}
|
||
parentUserID = parent.UserID
|
||
}
|
||
}
|
||
|
||
err = s.commentRepo.Create(comment)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 记录操作日志
|
||
if s.logService != nil {
|
||
s.logService.OperationLog.RecordOperation(&model.OperationLog{
|
||
UserID: userID,
|
||
Operation: string(model.OpCommentCreate),
|
||
Module: "comment",
|
||
TargetType: "comment",
|
||
TargetID: comment.ID,
|
||
Status: "success",
|
||
})
|
||
}
|
||
|
||
// 重新查询以获取关联的 User
|
||
comment, err = s.commentRepo.GetByID(comment.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 异步处理 @提及通知
|
||
if len(segments) > 0 {
|
||
bgCtx := context.WithoutCancel(ctx)
|
||
go s.processCommentMentionNotifications(bgCtx, userID, postID, post.Title, segments)
|
||
}
|
||
|
||
go s.reviewCommentAsync(comment.ID, userID, postID, content, imageURLs, parentID, parentUserID, post.UserID)
|
||
|
||
return comment, nil
|
||
}
|
||
|
||
// processCommentMentionNotifications 处理评论中 @提及的通知
|
||
func (s *CommentService) processCommentMentionNotifications(ctx context.Context, authorID, postID, postTitle string, segments model.MessageSegments) {
|
||
if s.systemMessageService == nil {
|
||
return
|
||
}
|
||
|
||
mentionedUserIDs := dto.ExtractMentionedUsers(segments)
|
||
if len(mentionedUserIDs) == 0 {
|
||
return
|
||
}
|
||
|
||
for _, targetUserID := range mentionedUserIDs {
|
||
if targetUserID == authorID {
|
||
continue
|
||
}
|
||
if err := s.systemMessageService.SendMentionNotification(ctx, targetUserID, authorID, postID); err != nil {
|
||
log.Printf("[WARN] Failed to send comment mention notification to user %s: %v", targetUserID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *CommentService) reviewCommentAsync(
|
||
commentID, userID, postID, content string,
|
||
imageURLs []string,
|
||
parentID *string,
|
||
parentUserID string,
|
||
postOwnerID string,
|
||
) {
|
||
if s.hookManager == nil {
|
||
if err := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished, "", "system"); err != nil {
|
||
zap.L().Warn("Failed to publish comment without hook manager",
|
||
zap.Error(err),
|
||
)
|
||
return
|
||
}
|
||
if err := s.applyCommentPublishedStats(commentID); err != nil {
|
||
zap.L().Warn("Failed to apply published stats for comment",
|
||
zap.String("commentID", commentID),
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
s.invalidatePostCaches(postID)
|
||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||
return
|
||
}
|
||
|
||
var authorID uint
|
||
fmt.Sscanf(userID, "%d", &authorID)
|
||
|
||
result := &hook.ModerationResult{}
|
||
s.hookManager.TriggerWithMetadata(context.Background(), hook.HookCommentPreModerate, authorID, &hook.CommentModerateHookData{
|
||
CommentID: commentID,
|
||
PostID: postID,
|
||
Content: content,
|
||
Images: imageURLs,
|
||
AuthorID: authorID,
|
||
ParentID: parentID,
|
||
}, map[string]any{
|
||
"result": result,
|
||
})
|
||
|
||
s.hookManager.Trigger(context.Background(), hook.HookCommentModerated, authorID, &hook.CommentModeratedHookData{
|
||
CommentID: commentID,
|
||
PostID: postID,
|
||
AuthorID: authorID,
|
||
Approved: result.Approved,
|
||
RejectReason: result.RejectReason,
|
||
ReviewedBy: result.ReviewedBy,
|
||
})
|
||
|
||
if !result.Approved {
|
||
if result.NeedsReview {
|
||
s.invalidatePostCaches(postID)
|
||
return
|
||
}
|
||
if delErr := s.commentRepo.Delete(commentID); delErr != nil {
|
||
zap.L().Warn("Failed to delete rejected comment",
|
||
zap.String("commentID", commentID),
|
||
zap.Error(delErr),
|
||
)
|
||
}
|
||
s.notifyCommentModerationRejected(userID, result.RejectReason)
|
||
return
|
||
}
|
||
|
||
if updateErr := s.commentRepo.UpdateModerationStatus(commentID, model.CommentStatusPublished, "", result.ReviewedBy); updateErr != nil {
|
||
zap.L().Warn("Failed to publish comment",
|
||
zap.String("commentID", commentID),
|
||
zap.Error(updateErr),
|
||
)
|
||
return
|
||
}
|
||
if statsErr := s.applyCommentPublishedStats(commentID); statsErr != nil {
|
||
zap.L().Warn("Failed to apply published stats for comment",
|
||
zap.String("commentID", commentID),
|
||
zap.Error(statsErr),
|
||
)
|
||
}
|
||
s.invalidatePostCaches(postID)
|
||
s.afterCommentPublished(userID, postID, commentID, parentID, parentUserID, postOwnerID)
|
||
}
|
||
|
||
func (s *CommentService) applyCommentPublishedStats(commentID string) error {
|
||
comment, err := s.commentRepo.GetByID(commentID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.commentRepo.ApplyPublishedStats(comment)
|
||
}
|
||
|
||
func (s *CommentService) invalidatePostCaches(postID string) {
|
||
cache.InvalidatePostDetail(s.cache, postID)
|
||
cache.InvalidatePostList(s.cache)
|
||
}
|
||
|
||
func (s *CommentService) afterCommentPublished(userID, postID, commentID string, parentID *string, parentUserID, postOwnerID string) {
|
||
// 发送系统消息通知
|
||
if s.systemMessageService != nil {
|
||
go func() {
|
||
if parentID != nil && parentUserID != "" {
|
||
// 回复评论,通知被回复的人
|
||
if parentUserID != userID {
|
||
notifyErr := s.systemMessageService.SendReplyNotification(context.Background(), parentUserID, userID, postID, *parentID, commentID)
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending reply notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}
|
||
} else {
|
||
// 评论帖子,通知帖子作者
|
||
if postOwnerID != userID {
|
||
notifyErr := s.systemMessageService.SendCommentNotification(context.Background(), postOwnerID, userID, postID, commentID)
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending comment notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
}
|
||
|
||
func (s *CommentService) notifyCommentModerationRejected(userID, reason string) {
|
||
if s.systemMessageService == nil || strings.TrimSpace(userID) == "" {
|
||
return
|
||
}
|
||
|
||
content := "您发布的评论未通过AI审核,请修改后重试。"
|
||
if strings.TrimSpace(reason) != "" {
|
||
content = fmt.Sprintf("您发布的评论未通过AI审核,原因:%s。请修改后重试。", reason)
|
||
}
|
||
|
||
go func() {
|
||
if err := s.systemMessageService.SendSystemAnnouncement(
|
||
context.Background(),
|
||
[]string{userID},
|
||
"评论审核未通过",
|
||
content,
|
||
); err != nil {
|
||
zap.L().Warn("Failed to send comment moderation reject notification",
|
||
zap.Error(err),
|
||
)
|
||
}
|
||
}()
|
||
}
|
||
|
||
// GetByID 根据ID获取评论
|
||
func (s *CommentService) GetByID(ctx context.Context, id string) (*model.Comment, error) {
|
||
return s.commentRepo.GetByID(id)
|
||
}
|
||
|
||
// GetByPostID 获取帖子评论
|
||
func (s *CommentService) GetByPostID(ctx context.Context, postID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
||
// 使用带回复的查询,默认加载前3条回复
|
||
return s.commentRepo.GetByPostIDWithReplies(postID, page, pageSize, 3)
|
||
}
|
||
|
||
// GetRepliesByRootID 根据根评论ID分页获取回复
|
||
func (s *CommentService) GetRepliesByRootID(ctx context.Context, rootID string, page, pageSize int) ([]*model.Comment, int64, error) {
|
||
return s.commentRepo.GetRepliesByRootID(rootID, page, pageSize)
|
||
}
|
||
|
||
// GetReplies 获取回复
|
||
func (s *CommentService) GetReplies(ctx context.Context, parentID string) ([]*model.Comment, error) {
|
||
return s.commentRepo.GetReplies(parentID)
|
||
}
|
||
|
||
// Update 更新评论
|
||
func (s *CommentService) Update(ctx context.Context, userID string, comment *model.Comment) error {
|
||
if comment.UserID != userID {
|
||
return apperrors.ErrForbidden
|
||
}
|
||
return s.commentRepo.Update(comment)
|
||
}
|
||
|
||
// Delete 删除评论
|
||
func (s *CommentService) Delete(ctx context.Context, userID string, id string) error {
|
||
comment, err := s.commentRepo.GetByID(id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if comment.UserID != userID {
|
||
return apperrors.ErrForbidden
|
||
}
|
||
if err := s.commentRepo.Delete(id); err != nil {
|
||
return err
|
||
}
|
||
s.invalidatePostCaches(comment.PostID)
|
||
return nil
|
||
}
|
||
|
||
// Like 点赞评论
|
||
func (s *CommentService) Like(ctx context.Context, commentID, userID string) error {
|
||
// 获取评论信息用于发送通知
|
||
comment, err := s.commentRepo.GetByID(commentID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = s.commentRepo.Like(commentID, userID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// 发送评论/回复点赞通知(只有不是给自己点赞时才发送)
|
||
if s.systemMessageService != nil && comment.UserID != userID {
|
||
go func() {
|
||
var notifyErr error
|
||
if comment.ParentID != nil {
|
||
notifyErr = s.systemMessageService.SendLikeReplyNotification(
|
||
context.Background(),
|
||
comment.UserID,
|
||
userID,
|
||
comment.PostID,
|
||
commentID,
|
||
comment.Content,
|
||
)
|
||
} else {
|
||
notifyErr = s.systemMessageService.SendLikeCommentNotification(
|
||
context.Background(),
|
||
comment.UserID,
|
||
userID,
|
||
comment.PostID,
|
||
commentID,
|
||
comment.Content,
|
||
)
|
||
}
|
||
if notifyErr != nil {
|
||
zap.L().Error("Error sending like notification",
|
||
zap.Error(notifyErr),
|
||
)
|
||
}
|
||
}()
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Unlike 取消点赞评论
|
||
func (s *CommentService) Unlike(ctx context.Context, commentID, userID string) error {
|
||
return s.commentRepo.Unlike(commentID, userID)
|
||
}
|
||
|
||
// IsLiked 检查是否已点赞
|
||
func (s *CommentService) IsLiked(ctx context.Context, commentID, userID string) bool {
|
||
return s.commentRepo.IsLiked(commentID, userID)
|
||
}
|
||
|
||
// GetCommentsByCursor 游标分页获取帖子评论
|
||
func (s *CommentService) GetCommentsByCursor(ctx context.Context, postID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||
return s.commentRepo.GetCommentsByCursor(ctx, postID, req)
|
||
}
|
||
|
||
// GetRepliesByCursor 游标分页获取根评论的回复
|
||
func (s *CommentService) GetRepliesByCursor(ctx context.Context, rootID string, req *cursor.PageRequest) (*cursor.CursorPageResult[*model.Comment], error) {
|
||
return s.commentRepo.GetRepliesByCursor(ctx, rootID, req)
|
||
}
|