From 43c35ddaf28f6efd7f744e1965f98f35098ddb90 Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Mon, 1 Jun 2026 14:05:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=AF=84=E8=AE=BA=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E6=A3=80=E6=9F=A5=E4=BF=AE=E5=A4=8D=EF=BC=88?= =?UTF-8?q?7=20=E9=A1=B9=20Gap=20=E5=85=A8=E9=83=A8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete 权限:新增 requesterID/isAdmin 参数,Service 层鉴权(仅本人/管理员可删) - 通知内容:改用 GetUsernameByID 显示真实用户名,非数字 UID - SearchUsers:Repository 返回 exp,Service 调用 GetLevelByExp 计算等级 - 通知高亮:Notification 新增 CommentID 字段,消息链接支持 #comment-{id} - 已删除提示:前端 hash 检测 + 3 秒超时 Toast "该评论已不存在" - Admin CSS:移除不存在的 comments.css 引用 - 错误哨兵:改用 common.ErrCommentNotFound/ErrCommentEmpty/PermissionDenied --- .../admin/admin_comment_controller.go | 5 +- internal/controller/admin/interfaces.go | 2 +- internal/controller/comment_controller.go | 16 +++--- internal/controller/interfaces.go | 2 +- internal/model/notification.go | 3 +- internal/repository/comment_repo.go | 21 +++++--- internal/service/comment_service.go | 37 +++++++++----- internal/service/level_service.go | 2 +- internal/service/notification_service.go | 11 ++-- internal/service/repository.go | 8 +-- .../MetaLab-2026/html/messages/index.html | 6 ++- templates/MetaLab-2026/html/posts/show.html | 51 ++++++++++++++++++- 12 files changed, 119 insertions(+), 45 deletions(-) diff --git a/internal/controller/admin/admin_comment_controller.go b/internal/controller/admin/admin_comment_controller.go index 6a45d0b..75f6d09 100644 --- a/internal/controller/admin/admin_comment_controller.go +++ b/internal/controller/admin/admin_comment_controller.go @@ -23,8 +23,7 @@ func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController { // CommentsPage SSR 页面 func (ctrl *AdminCommentController) CommentsPage(c *gin.Context) { c.HTML(http.StatusOK, "admin/comments/index.html", common.BuildAdminPageData(c, gin.H{ - "Title": "评论管理", - "ExtraCSS": "/admin/static/css/comments.css", + "Title": "评论管理", })) } @@ -58,7 +57,7 @@ func (ctrl *AdminCommentController) Delete(c *gin.Context) { common.Error(c, http.StatusBadRequest, "无效的评论ID") return } - if err := ctrl.commentService.Delete(uint(commentID)); err != nil { + if err := ctrl.commentService.Delete(uint(commentID), 0, true); err != nil { common.Error(c, http.StatusBadRequest, err.Error()) return } diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index 8250fcd..625c5c4 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -50,5 +50,5 @@ type adminPostUseCase interface { // adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖(ISP:2 个方法) type adminCommentUseCase interface { ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) - Delete(commentID uint) error + Delete(commentID uint, requesterID uint, isAdmin bool) error } diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index 6cf7170..5234f09 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -2,6 +2,7 @@ package controller import ( "bytes" + "errors" "image" "image/jpeg" "image/png" @@ -147,14 +148,13 @@ func (ctrl *CommentController) Delete(c *gin.Context) { return } - // 权限检查:仅本人或管理员可删除 - _ = uid - _ = role - // 此处权限在 service 层不做额外判断,允许任何人请求删除 - // 后端检查在之前 FindByID 之后可判断 comment.user_id vs current uid - // 为简化,暂时信任前端权限,后续在 service 可增加 - - if err := ctrl.commentService.Delete(uint(commentID)); err != nil { + // 权限检查由 service 层统一处理 + isAdmin := model.HasMinRole(role, model.RoleAdmin) + if err := ctrl.commentService.Delete(uint(commentID), uid, isAdmin); err != nil { + if errors.Is(err, common.ErrPermissionDenied) { + common.Error(c, http.StatusForbidden, err.Error()) + return + } common.Error(c, http.StatusBadRequest, err.Error()) return } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index 58e710a..a6341fe 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -96,7 +96,7 @@ type energyAdminUseCase interface { type commentUseCase interface { CreateRoot(userID, postID uint, body string) (*model.Comment, error) CreateReply(userID, parentID uint, body string) (*model.Comment, error) - Delete(commentID uint) error + Delete(commentID uint, requesterID uint, isAdmin bool) error ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) ListReplies(rootID uint) ([]model.Comment, error) SearchUsers(keyword string) ([]model.UserSearchResult, error) diff --git a/internal/model/notification.go b/internal/model/notification.go index f1c025e..b3da1f4 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -20,7 +20,8 @@ type Notification struct { Title string `gorm:"type:varchar(200);not null" json:"title"` Content string `gorm:"type:text" json:"content"` IsRead bool `gorm:"index:idx_user_read,priority:2;default:false;not null" json:"is_read"` - RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID + RelatedID *uint `gorm:"default:null" json:"related_id,omitempty"` // 关联业务 ID(文章/评论等) + CommentID *uint `gorm:"default:null" json:"comment_id,omitempty"` // 评论 ID(用于跳转高亮目标评论) } // NotifyTypeNames 通知类型 → 中文名 diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index 6d84a28..1c64376 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -185,23 +185,23 @@ func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMent return list, err } -// SearchUsersByLevel 搜索 LV3+ 用户(用于@艾特) +// SearchUsersByLevel 搜索指定等级及以上用户(用于@艾特),返回 uid/username/exp func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct { UID string Username string + Exp int }, error) { - // LV3 = 1500 exp type result struct { - UID string + UID uint Username string + Exp int } var list []result - // level 是计算字段,用 exp >= thresholds[minLevel] 判断 threshold := model.LevelThresholds[minLevel] err := r.db.Table("users"). - Select("uid, username"). + Select("uid, username, exp"). Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold). Order("exp DESC"). Limit(limit). @@ -210,12 +210,14 @@ func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int var res []struct { UID string Username string + Exp int } for _, u := range list { res = append(res, struct { UID string Username string - }{UID: u.UID, Username: u.Username}) + Exp int + }{UID: strconv.FormatUint(uint64(u.UID), 10), Username: u.Username, Exp: u.Exp}) } return res, err } @@ -230,6 +232,13 @@ func (r *CommentRepo) GetUserUIDByID(userID uint) (string, error) { return strconv.FormatUint(uint64(uid), 10), nil } +// GetUsernameByID 通过 userID 获取用户名(用于通知内容) +func (r *CommentRepo) GetUsernameByID(userID uint) (string, error) { + var username string + err := r.db.Table("users").Select("username").Where("uid = ?", userID).Scan(&username).Error + return username, err +} + // FindPostIDByComment 查询评论所属的文章ID func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) { var postID uint diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index 7cbf0d0..77463e2 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -28,7 +28,7 @@ func NewCommentService(repo commentStore, notifier commentNotifier) *CommentServ // CreateRoot 创建顶级评论 func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) { if strings.TrimSpace(body) == "" { - return nil, errors.New("评论内容不能为空") + return nil, common.ErrCommentEmpty } postAuthorID, err := s.repo.FindPostAuthorID(postID) @@ -60,11 +60,14 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod // 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接 if s.notifier != nil && userID != postAuthorID { - commenterUID, _ := s.repo.GetUserUIDByID(userID) + commenterName, _ := s.repo.GetUsernameByID(userID) + if commenterName == "" { + commenterName = "用户" + } s.notifier.Create(postAuthorID, model.NotifyComment, "新评论", - fmt.Sprintf("%s 评论了你的文章", commenterUID), - &comment.PostID) + fmt.Sprintf("%s 评论了你的文章", commenterName), + &comment.PostID, &comment.ID) } return comment, nil @@ -73,13 +76,13 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod // CreateReply 创建回复 func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) { if strings.TrimSpace(body) == "" { - return nil, errors.New("回复内容不能为空") + return nil, common.ErrCommentEmpty } parent, err := s.repo.FindByID(parentID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("父评论不存在") + return nil, common.ErrCommentNotFound } return nil, fmt.Errorf("查询父评论失败: %w", err) } @@ -108,22 +111,25 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (* // 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接 if s.notifier != nil && parent.UserID != userID { - commenterUID, _ := s.repo.GetUserUIDByID(userID) + commenterName, _ := s.repo.GetUsernameByID(userID) + if commenterName == "" { + commenterName = "用户" + } s.notifier.Create(parent.UserID, model.NotifyCommentReply, "新回复", - fmt.Sprintf("%s 回复了你的评论", commenterUID), - &parent.PostID) + fmt.Sprintf("%s 回复了你的评论", commenterName), + &parent.PostID, &comment.ID) } return comment, nil } -// Delete 软删除评论 -func (s *CommentService) Delete(commentID uint) error { +// Delete 软删除评论(requesterID 为请求者,isAdmin 表示是否为管理员) +func (s *CommentService) Delete(commentID uint, requesterID uint, isAdmin bool) error { comment, err := s.repo.FindByID(commentID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return errors.New("评论不存在") + return common.ErrCommentNotFound } return fmt.Errorf("查询评论失败: %w", err) } @@ -132,6 +138,11 @@ func (s *CommentService) Delete(commentID uint) error { return nil } + // 权限检查:仅本人或管理员可删除 + if !isAdmin && comment.UserID != requesterID { + return common.ErrPermissionDenied + } + if err := s.repo.SoftDelete(commentID); err != nil { return fmt.Errorf("删除评论失败: %w", err) } @@ -182,7 +193,7 @@ func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, users[i] = model.UserSearchResult{ UID: row.UID, Username: row.Username, - Level: 0, + Level: model.GetLevelByExp(row.Exp), } } return users, nil diff --git a/internal/service/level_service.go b/internal/service/level_service.go index 9f84e3b..15a4131 100644 --- a/internal/service/level_service.go +++ b/internal/service/level_service.go @@ -84,7 +84,7 @@ func (s *LevelService) AddExp(userID uint, delta int) (newExp int, levelUp bool, _ = s.notifSvc.Create(userID, model.NotifyLevelUp, "等级提升", fmt.Sprintf("恭喜您!您的等级已提升至 %s", model.GetLevelName(newLevel)), - nil) + nil, nil) } } diff --git a/internal/service/notification_service.go b/internal/service/notification_service.go index 1356fd4..828be0b 100644 --- a/internal/service/notification_service.go +++ b/internal/service/notification_service.go @@ -26,7 +26,7 @@ func NewNotificationService(repo notifRepo) *NotificationService { } // Create 创建消息 -func (s *NotificationService) Create(userID uint, notifyType, title, content string, relatedID *uint) error { +func (s *NotificationService) Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error { n := &model.Notification{ UserID: userID, NotifyType: notifyType, @@ -34,6 +34,7 @@ func (s *NotificationService) Create(userID uint, notifyType, title, content str Content: content, IsRead: false, RelatedID: relatedID, + CommentID: commentID, } return s.repo.Create(n) } @@ -92,7 +93,7 @@ func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, _ = s.Create(userID, model.NotifyAuditApproved, "审核已通过", "你的"+typeName+"修改已通过审核", - &relatedID) + &relatedID, nil) } // NotifyAuditRejected 审核拒绝通知 @@ -108,14 +109,14 @@ func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason _ = s.Create(userID, model.NotifyAuditRejected, "审核未通过", content, - &relatedID) + &relatedID, nil) } // NotifyPostApproved 稿件审核通过通知 func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) { title := "稿件审核通过" content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布" - _ = s.Create(userID, model.NotifyPostApproved, title, content, &postID) + _ = s.Create(userID, model.NotifyPostApproved, title, content, &postID, nil) } // NotifyPostRejected 稿件审核拒绝通知 @@ -125,5 +126,5 @@ func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason if reason != "" { content += ",理由:" + reason } - _ = s.Create(userID, model.NotifyPostRejected, title, content, &postID) + _ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil) } diff --git a/internal/service/repository.go b/internal/service/repository.go index ea050d9..4389d2e 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -86,7 +86,7 @@ type taskStore interface { // levelNotifier 等级升级通知接口 type levelNotifier interface { - Create(userID uint, notifyType, title, content string, relatedID *uint) error + Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error } // EnergyStore EnergyService 所需的完整仓储接口 @@ -113,7 +113,7 @@ type energyStore = EnergyStore // energyNotifier EnergyService 所需的通知接口(ISP) type energyNotifier interface { - Create(userID uint, notifyType, title, content string, relatedID *uint) error + Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error } // commentStore CommentService 所需的最小仓储接口(ISP) @@ -132,12 +132,14 @@ type commentStore interface { SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct { UID string Username string + Exp int }, error) GetUserUIDByID(userID uint) (string, error) + GetUsernameByID(userID uint) (string, error) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) } // commentNotifier CommentService 所需的通知接口(ISP) type commentNotifier interface { - Create(userID uint, notifyType, title, content string, relatedID *uint) error + Create(userID uint, notifyType, title, content string, relatedID, commentID *uint) error } diff --git a/templates/MetaLab-2026/html/messages/index.html b/templates/MetaLab-2026/html/messages/index.html index 3fec2f5..240d136 100644 --- a/templates/MetaLab-2026/html/messages/index.html +++ b/templates/MetaLab-2026/html/messages/index.html @@ -42,7 +42,11 @@ {{if or (eq $m.NotifyType "post_approved") (eq $m.NotifyType "post_rejected")}} {{$link = printf "/posts/%d" (derefUint $m.RelatedID)}} {{else if or (eq $m.NotifyType "comment") (eq $m.NotifyType "comment_reply")}} - {{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}} + {{if $m.CommentID}} + {{$link = printf "/posts/%d#comment-%d" (derefUint $m.RelatedID) (derefUint $m.CommentID)}} + {{else}} + {{$link = printf "/posts/%d#comments" (derefUint $m.RelatedID)}} + {{end}} {{else if or (eq $m.NotifyType "audit_approved") (eq $m.NotifyType "audit_rejected")}} {{$link = "/settings"}} {{end}} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index 0510e4b..6d8ce05 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -703,8 +703,55 @@ }); } - // ====== Hash 定位:从消息通知跳转时滚动到评论区 ====== - if (window.location.hash === '#comments') { + // ====== 评论高亮 + 已删除评论Toast ====== + function showToast(msg) { + var toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = msg; + toast.style.cssText = 'position:fixed;top:20px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.85);color:#fff;padding:10px 24px;border-radius:6px;font-size:14px;z-index:9999;animation:fadeIn 0.3s ease'; + document.body.appendChild(toast); + setTimeout(function() { + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s'; + setTimeout(function() { toast.remove(); }, 300); + }, 2500); + } + + function highlightComment(commentId) { + var card = commentsList.querySelector('.comment-card[data-id="' + commentId + '"]') || + commentsList.querySelector('.comment-reply-card[data-id="' + commentId + '"]'); + if (card) { + // 置顶:移动卡片到列表顶部 + commentsList.insertBefore(card, commentsList.firstChild); + card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + card.style.background = '#fff8e1'; + card.style.transition = 'background 1.5s'; + setTimeout(function() { card.style.background = ''; }, 2000); + return true; + } + return false; + } + + // 解析 hash(支持 #comment-{id}、#comments 以及回退) + var hash = window.location.hash; + var commentMatch = hash.match(/^#comment-(\d+)$/); + if (commentMatch) { + var targetCommentId = commentMatch[1]; + // 等待评论加载完成后查找 + var checkInterval = setInterval(function() { + if (highlightComment(targetCommentId)) { + clearInterval(checkInterval); + } + }, 300); + // 3秒后超时(评论可能已被删除) + setTimeout(function() { + clearInterval(checkInterval); + if (!commentsList.querySelector('.comment-card[data-id="' + targetCommentId + '"]') && + !commentsList.querySelector('.comment-reply-card[data-id="' + targetCommentId + '"]')) { + showToast('该评论已不存在'); + } + }, 3000); + } else if (hash === '#comments') { setTimeout(function() { var section = document.getElementById('comments'); if (section) {