From e45d66fb778edd7d29f7393fbd361f985be5e26a Mon Sep 17 00:00:00 2001 From: Victor_Jay Date: Thu, 28 May 2026 14:44:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B8=96=E5=AD=90?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=80=BB=E8=BE=91=E7=9A=84=E4=B8=A5=E9=87=8D?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E4=B8=8E=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Service 层所有 FindByID 错误均返回 ErrUserNotFound 的严重 bug,新增 findPost/findPostWithAuthor 正确区分 gorm.ErrRecordNotFound 和数据库错误 - 新增帖子相关错误哨兵:ErrPostNotFound/ErrPostCannotEdit/ErrPostCannotSubmit/ErrPostCannotApprove/ErrPostCannotReject/ErrPostCannotUnlock - 修复 ShowAPI 缺少权限控制,非 approved 帖子可被任意用户通过 API 获取 - 修复 EditPage 缺少权限检查,非作者可访问编辑页面 - 修复 Update/Delete API 中 uid 断言未做 ok 检查 - Controller 层区分业务错误和内部错误,避免透传 Service 层错误信息给客户端 - 将 postStore 接口从 post_service.go 移至 repository.go,与其他仓储接口保持一致 - 移除 new.html 中孤立的 标签 --- internal/common/errors.go | 8 ++ .../controller/admin/admin_post_controller.go | 27 +++++-- internal/controller/post_controller.go | 59 +++++++++++++- internal/service/post_service.go | 80 +++++++++++-------- internal/service/repository.go | 13 ++- templates/MetaLab-2026/html/posts/new.html | 1 - 6 files changed, 141 insertions(+), 47 deletions(-) diff --git a/internal/common/errors.go b/internal/common/errors.go index 571c778..c9793e0 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -35,4 +35,12 @@ var ( ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号") ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限") ErrRegistrationDisabled = errors.New("注册功能已关闭") + + // 帖子相关 + ErrPostNotFound = errors.New("帖子不存在") + ErrPostCannotEdit = errors.New("当前状态不允许编辑") + ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核") + ErrPostCannotApprove = errors.New("仅待审核帖子可通过") + ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回") + ErrPostCannotUnlock = errors.New("仅锁定状态可解锁") ) diff --git a/internal/controller/admin/admin_post_controller.go b/internal/controller/admin/admin_post_controller.go index 64ae5f9..919ac14 100644 --- a/internal/controller/admin/admin_post_controller.go +++ b/internal/controller/admin/admin_post_controller.go @@ -1,6 +1,7 @@ package admin import ( + "errors" "net/http" "strconv" @@ -72,7 +73,11 @@ func (ctrl *AdminPostController) Approve(c *gin.Context) { } if err := ctrl.postService.Approve(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "审核失败") + } return } @@ -94,7 +99,11 @@ func (ctrl *AdminPostController) Reject(c *gin.Context) { } if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "退回失败") + } return } @@ -110,7 +119,11 @@ func (ctrl *AdminPostController) Lock(c *gin.Context) { } if err := ctrl.postService.Lock(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "锁定失败") + } return } @@ -126,7 +139,11 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) { } if err := ctrl.postService.Unlock(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "解锁失败") + } return } @@ -142,7 +159,7 @@ func (ctrl *AdminPostController) Restore(c *gin.Context) { } if err := ctrl.postService.Restore(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + common.Error(c, http.StatusInternalServerError, "恢复失败") return } diff --git a/internal/controller/post_controller.go b/internal/controller/post_controller.go index d42e131..df14921 100644 --- a/internal/controller/post_controller.go +++ b/internal/controller/post_controller.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "html/template" "mime/multipart" @@ -144,6 +145,22 @@ func (ctrl *PostController) EditPage(c *gin.Context) { return } + // 权限检查:仅作者和 moderator+ 可编辑 + var uid uint + var role string + if uidObj, exists := c.Get("uid"); exists { + uid, _ = uidObj.(uint) + } + if roleObj, exists := c.Get("role"); exists { + role, _ = roleObj.(string) + } + if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) { + c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{ + "Title": "未找到", + })) + return + } + c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{ "Title": "编辑帖子", "Post": post, @@ -178,7 +195,11 @@ func (ctrl *PostController) Create(c *gin.Context) { // Update API 编辑帖子 func (ctrl *PostController) Update(c *gin.Context) { uidObj, _ := c.Get("uid") - uid, _ := uidObj.(uint) + uid, ok := uidObj.(uint) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -206,7 +227,11 @@ func (ctrl *PostController) Update(c *gin.Context) { } if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostCannotEdit) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "编辑失败") + } return } @@ -216,7 +241,11 @@ func (ctrl *PostController) Update(c *gin.Context) { // Delete API 删除帖子 func (ctrl *PostController) Delete(c *gin.Context) { uidObj, _ := c.Get("uid") - uid, _ := uidObj.(uint) + uid, ok := uidObj.(uint) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { @@ -254,7 +283,11 @@ func (ctrl *PostController) Submit(c *gin.Context) { } if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil { - common.Error(c, http.StatusBadRequest, err.Error()) + if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) { + common.Error(c, http.StatusBadRequest, err.Error()) + } else { + common.Error(c, http.StatusInternalServerError, "提交审核失败") + } return } @@ -303,6 +336,24 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) { return } + // 权限控制:非 approved 帖子仅作者和 moderator+ 可见 + if post.Status != model.PostStatusApproved { + var uid uint + var role string + if uidObj, exists := c.Get("uid"); exists { + uid, _ = uidObj.(uint) + } + if roleObj, exists := c.Get("role"); exists { + role, _ = roleObj.(string) + } + isAuthor := uid == post.UserID + isModerator := model.HasMinRole(role, model.RoleModerator) + if !isAuthor && !isModerator { + common.Error(c, http.StatusNotFound, "帖子不存在") + return + } + } + common.Ok(c, post) } diff --git a/internal/service/post_service.go b/internal/service/post_service.go index 9edf841..f396eda 100644 --- a/internal/service/post_service.go +++ b/internal/service/post_service.go @@ -1,6 +1,7 @@ package service import ( + "errors" "fmt" "regexp" @@ -9,6 +10,7 @@ import ( "metazone.cc/metalab/internal/model" "github.com/microcosm-cc/bluemonday" + "gorm.io/gorm" ) // sanitizePolicy Tiptap 输出 HTML 的消毒策略 @@ -24,18 +26,6 @@ var sanitizePolicy = func() *bluemonday.Policy { return p }() -// postStore 帖子服务所需的最小仓储接口(ISP) -type postStore interface { - Create(post *model.Post) error - FindByID(id uint) (*model.Post, error) - FindByIDWithAuthor(id uint) (*model.Post, error) - FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) - FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) - Update(post *model.Post) error - SoftDelete(id uint) error - Restore(id uint) error -} - // PostService 帖子业务逻辑 type PostService struct { repo postStore @@ -55,6 +45,30 @@ func (s *PostService) auditEnabled() bool { return s.ss.IsAuditEnabled() } +// findPost 按 ID 查找帖子,区分"未找到"和"数据库错误" +func (s *PostService) findPost(id uint) (*model.Post, error) { + post, err := s.repo.FindByID(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrPostNotFound + } + return nil, fmt.Errorf("查询帖子失败: %w", err) + } + return post, nil +} + +// findPostWithAuthor 按 ID 查找帖子(含作者名),区分"未找到"和"数据库错误" +func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) { + post, err := s.repo.FindByIDWithAuthor(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, common.ErrPostNotFound + } + return nil, fmt.Errorf("查询帖子失败: %w", err) + } + return post, nil +} + // sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒 // 前端 WYSIWYG 编辑器直接输出 HTML,后端仅做安全消毒 func (s *PostService) sanitizeHTML(body string) string { @@ -86,11 +100,7 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro // GetByID 按 ID 获取帖子(含作者名,仅 approved 公开可见) func (s *PostService) GetByID(id uint) (*model.Post, error) { - post, err := s.repo.FindByIDWithAuthor(id) - if err != nil { - return nil, common.ErrUserNotFound // 复用哨兵表示资源不存在 - } - return post, nil + return s.findPostWithAuthor(id) } // List 公开帖子列表(仅 approved) @@ -109,13 +119,13 @@ func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]m // Update 编辑帖子(权限在 controller 层检查) func (s *PostService) Update(postID uint, title, body string) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked { - return fmt.Errorf("当前状态不允许编辑") + return common.ErrPostCannotEdit } post.Title = title @@ -138,12 +148,12 @@ func (s *PostService) Delete(postID uint) error { // SubmitForAudit 提交审核:draft → pending func (s *PostService) SubmitForAudit(postID uint) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected { - return fmt.Errorf("仅草稿和已退回帖子可提交审核") + return common.ErrPostCannotSubmit } post.Status = model.PostStatusPending return s.repo.Update(post) @@ -151,12 +161,12 @@ func (s *PostService) SubmitForAudit(postID uint) error { // Approve 审核通过:pending → approved func (s *PostService) Approve(postID uint) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } if post.Status != model.PostStatusPending { - return fmt.Errorf("仅待审核帖子可通过") + return common.ErrPostCannotApprove } post.Status = model.PostStatusApproved post.RejectReason = "" @@ -165,23 +175,23 @@ func (s *PostService) Approve(postID uint) error { // Reject 退回:pending/approved → rejected func (s *PostService) Reject(postID uint, reason string) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved { - return fmt.Errorf("仅待审核和已发布帖子可退回") + return common.ErrPostCannotReject } post.Status = model.PostStatusRejected post.RejectReason = reason return s.repo.Update(post) } -// Lock 锁定:任意状态 → locked +// Lock 锁定:任意状态 → locked(管理员可随时锁定) func (s *PostService) Lock(postID uint) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } post.Status = model.PostStatusLocked return s.repo.Update(post) @@ -189,12 +199,12 @@ func (s *PostService) Lock(postID uint) error { // Unlock 解锁:locked → 草稿 func (s *PostService) Unlock(postID uint) error { - post, err := s.repo.FindByID(postID) + post, err := s.findPost(postID) if err != nil { - return common.ErrUserNotFound + return err } if post.Status != model.PostStatusLocked { - return fmt.Errorf("仅锁定状态可解锁") + return common.ErrPostCannotUnlock } post.Status = model.PostStatusDraft return s.repo.Update(post) diff --git a/internal/service/repository.go b/internal/service/repository.go index e1a0c8b..6c4c2a5 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -31,5 +31,14 @@ type userAdminStore interface { IncrementTokenVersion(userID uint) error } - - +// postStore PostService 所需的最小仓储接口(ISP:8 个方法) +type postStore interface { + Create(post *model.Post) error + FindByID(id uint) (*model.Post, error) + FindByIDWithAuthor(id uint) (*model.Post, error) + FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error) + FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error) + Update(post *model.Post) error + SoftDelete(id uint) error + Restore(id uint) error +} \ No newline at end of file diff --git a/templates/MetaLab-2026/html/posts/new.html b/templates/MetaLab-2026/html/posts/new.html index c32cba0..4664828 100644 --- a/templates/MetaLab-2026/html/posts/new.html +++ b/templates/MetaLab-2026/html/posts/new.html @@ -282,4 +282,3 @@ -