diff --git a/internal/controller/admin/admin_comment_controller.go b/internal/controller/admin/admin_comment_controller.go new file mode 100644 index 0000000..6a45d0b --- /dev/null +++ b/internal/controller/admin/admin_comment_controller.go @@ -0,0 +1,66 @@ +package admin + +import ( + "net/http" + "strconv" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// AdminCommentController 后台评论管理 +type AdminCommentController struct { + commentService adminCommentUseCase +} + +// NewAdminCommentController 构造函数 +func NewAdminCommentController(cs adminCommentUseCase) *AdminCommentController { + return &AdminCommentController{commentService: cs} +} + +// 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", + })) +} + +// ListComments API 列表 +func (ctrl *AdminCommentController) ListComments(c *gin.Context) { + keyword := c.Query("keyword") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + comments, total, err := ctrl.commentService.ListAllComments(keyword, false, page, pageSize) + if err != nil { + common.Error(c, http.StatusInternalServerError, "获取评论列表失败") + return + } + if comments == nil { + comments = []model.AdminCommentRow{} + } + + common.Ok(c, gin.H{ + "items": comments, + "total": total, + "page": page, + "total_pages": common.PageCount(total, pageSize), + }) +} + +// Delete 软删除评论 +func (ctrl *AdminCommentController) Delete(c *gin.Context) { + commentID, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + common.Error(c, http.StatusBadRequest, "无效的评论ID") + return + } + if err := ctrl.commentService.Delete(uint(commentID)); err != nil { + common.Error(c, http.StatusBadRequest, err.Error()) + return + } + common.OkMessage(c, "删除成功") +} diff --git a/internal/controller/admin/interfaces.go b/internal/controller/admin/interfaces.go index d29f228..8250fcd 100644 --- a/internal/controller/admin/interfaces.go +++ b/internal/controller/admin/interfaces.go @@ -46,3 +46,9 @@ type adminPostUseCase interface { Unlock(postID uint) error Restore(postID uint) error } + +// adminCommentUseCase AdminCommentController 对 CommentService 的最小依赖(ISP:2 个方法) +type adminCommentUseCase interface { + ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) + Delete(commentID uint) error +} diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go index edcecb1..6cf7170 100644 --- a/internal/controller/comment_controller.go +++ b/internal/controller/comment_controller.go @@ -1,9 +1,17 @@ package controller import ( - "encoding/json" + "bytes" + "image" + "image/jpeg" + "image/png" + "io" "net/http" + "os" + "path/filepath" "strconv" + "strings" + "time" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/model" @@ -177,8 +185,102 @@ func (ctrl *CommentController) SearchUsers(c *gin.Context) { common.Ok(c, users) } -// marshalJSON helper -func marshalJSON(v interface{}) string { - b, _ := json.Marshal(v) - return string(b) +// UploadImage 上传评论图片 POST /api/comments/upload-image +// 需要 LV4+(Exp ≥ 1500),复用帖子图片上传的 WebP 转码逻辑 +func (ctrl *CommentController) UploadImage(c *gin.Context) { + uid, _, ok := common.GetGinUser(c) + if !ok { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + // LV4+ 检查(Exp ≥ 1500) + expVal, exists := c.Get("exp") + if !exists { + common.Error(c, http.StatusForbidden, "无法获取经验值") + return + } + exp := expVal.(int) + if exp < 1500 { + common.Error(c, http.StatusForbidden, "需 Lv4 以上才能上传评论图片") + return + } + + file, header, err := c.Request.FormFile("file") + if err != nil { + common.Error(c, http.StatusBadRequest, "请选择文件") + return + } + defer file.Close() + + // 检查扩展名 + ext := strings.ToLower(filepath.Ext(header.Filename)) + allowedExt := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} + if !allowedExt[ext] { + common.Error(c, http.StatusBadRequest, "仅支持 jpg/jpeg/png/gif/webp 格式") + return + } + + // 限制 5MB + if header.Size > 5<<20 { + common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB") + return + } + + data, err := io.ReadAll(file) + if err != nil { + common.Error(c, http.StatusInternalServerError, "读取文件失败") + return + } + + storageDir := "storage/uploads/posts" + if err := os.MkdirAll(storageDir, 0755); err != nil { + common.Error(c, http.StatusInternalServerError, "存储初始化失败") + return + } + + ts := time.Now().UnixMilli() + isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png" + + var savePath, url string + + // JPEG/PNG 转 WebP + if isJPEGPNG { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + common.Error(c, http.StatusBadRequest, "图片格式无效") + return + } + webpData := encodeWebPBinary(img, len(data)) + if webpData != nil && len(webpData) < len(data) { + filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ".webp" + savePath = filepath.Join(storageDir, filename) + os.WriteFile(savePath, webpData, 0644) + url = "/uploads/posts/" + filename + } + } + + // 回退存储 + if savePath == "" { + dataOut := data + if isJPEGPNG { + decImg, _, decErr := image.Decode(bytes.NewReader(data)) + if decErr == nil { + var buf bytes.Buffer + if ext == ".png" { + png.Encode(&buf, decImg) + } else { + jpeg.Encode(&buf, decImg, &jpeg.Options{Quality: 92}) + } + dataOut = buf.Bytes() + } + } + filename := strconv.FormatUint(uint64(uid), 10) + "_" + strconv.FormatInt(ts, 10) + ext + savePath = filepath.Join(storageDir, filename) + os.WriteFile(savePath, dataOut, 0644) + url = "/uploads/posts/" + filename + } + + // 返回标准 JSON(前端将 URL 插入评论文本) + common.Ok(c, gin.H{"url": url}) } diff --git a/internal/model/comment.go b/internal/model/comment.go index 36ad642..ac292cd 100644 --- a/internal/model/comment.go +++ b/internal/model/comment.go @@ -40,3 +40,15 @@ type UserSearchResult struct { Username string `json:"username"` Level int `json:"level"` } + +// AdminCommentRow 后台评论列表行 +type AdminCommentRow struct { + ID uint `json:"id"` + PostID uint `json:"post_id"` + PostTitle string `json:"post_title"` + Body string `json:"body"` + AuthorName string `json:"author_name"` + IsDeleted bool `json:"is_deleted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go index e965d72..6d84a28 100644 --- a/internal/repository/comment_repo.go +++ b/internal/repository/comment_repo.go @@ -248,3 +248,31 @@ func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) { err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error return authorID, err } + +// ListAllComments 后台评论列表(支持关键词搜索、分页) +func (r *CommentRepo) ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) { + var total int64 + q := r.db.Table("comments"). + Joins("LEFT JOIN users ON users.uid = comments.user_id"). + Joins("LEFT JOIN posts ON posts.id = comments.post_id") + + if !showDeleted { + q = q.Where("comments.is_deleted = false") + } + if keyword != "" { + like := "%" + keyword + "%" + q = q.Where("comments.body LIKE ? OR users.username LIKE ? OR posts.title LIKE ?", like, like, like) + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var rows []model.AdminCommentRow + err := q. + Select("comments.id, comments.post_id, posts.title as post_title, comments.body, users.username as author_name, comments.is_deleted, comments.created_at, comments.updated_at"). + Order("comments.created_at DESC"). + Offset(offset).Limit(limit). + Scan(&rows).Error + return rows, total, err +} diff --git a/internal/router/admin.go b/internal/router/admin.go index 124f446..e055673 100644 --- a/internal/router/admin.go +++ b/internal/router/admin.go @@ -30,6 +30,7 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { middleware.RequirePageRole(model.RoleOwner)) adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage) adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage) + adminPages.GET("/comments", d.adminCommentCtrl.CommentsPage) } // --- 管理后台 API --- @@ -85,4 +86,15 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { middleware.RequireMinRole(model.RoleOwner)) adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs) } + + // --- 评论管理 API(admin+) --- + adminCommentAPI := r.Group("/api/admin/comments") + adminCommentAPI.Use(d.authMdw.Required()) + adminCommentAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) + adminCommentAPI.Use(d.authMdw.BannedWriteGuard()) + adminCommentAPI.Use(middleware.CSRF(cfg)) + { + adminCommentAPI.GET("", d.adminCommentCtrl.ListComments) + adminCommentAPI.DELETE("/:id", d.adminCommentCtrl.Delete) + } } diff --git a/internal/router/api.go b/internal/router/api.go index f5881a3..25a48ec 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -116,5 +116,6 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply) commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete) commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers) + commentsAPI.POST("/comments/upload-image", d.commentCtrl.UploadImage) } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 985b7fe..9878fc3 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -36,6 +36,7 @@ type dependencies struct { energyService *service.EnergyService commentCtrl *controller.CommentController commentService *service.CommentService + adminCommentCtrl *adminCtrl.AdminCommentController } func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 4a4d0d3..755ddff 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -64,6 +64,9 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting commentService := service.NewCommentService(commentRepo, notificationService) commentCtrl := controller.NewCommentController(commentService) + // 后台评论管理 + adminCommentCtrl := adminCtrl.NewAdminCommentController(commentService) + // 用户空间 spaceService := service.NewSpaceService(userRepo, postRepo) spaceCtrl := controller.NewSpaceController(spaceService) @@ -84,5 +87,6 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting energyService: energyService, commentCtrl: commentCtrl, commentService: commentService, + adminCommentCtrl: adminCommentCtrl, } } diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go index a6db85c..7cbf0d0 100644 --- a/internal/service/comment_service.go +++ b/internal/service/comment_service.go @@ -58,13 +58,13 @@ func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*mod // 解析 @ 提及 s.parseMentions(comment.ID, body) - // 通知文章作者(评论者 ≠ 作者) + // 通知文章作者(评论者 ≠ 作者),RelatedID 存 postID 以便消息页生成跳转链接 if s.notifier != nil && userID != postAuthorID { commenterUID, _ := s.repo.GetUserUIDByID(userID) s.notifier.Create(postAuthorID, model.NotifyComment, "新评论", fmt.Sprintf("%s 评论了你的文章", commenterUID), - &comment.ID) + &comment.PostID) } return comment, nil @@ -106,13 +106,13 @@ func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (* // 解析 @ 提及 s.parseMentions(comment.ID, body) - // 通知被回复者(不自己回复自己) + // 通知被回复者(不自己回复自己),RelatedID 存 postID 以便消息页生成跳转链接 if s.notifier != nil && parent.UserID != userID { commenterUID, _ := s.repo.GetUserUIDByID(userID) s.notifier.Create(parent.UserID, model.NotifyCommentReply, "新回复", fmt.Sprintf("%s 回复了你的评论", commenterUID), - &comment.ID) + &parent.PostID) } return comment, nil @@ -159,6 +159,17 @@ func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) { return list, nil } +// ListAllComments 后台评论列表(分页+搜索) +func (s *CommentService) ListAllComments(keyword string, showDeleted bool, page, pageSize int) ([]model.AdminCommentRow, int64, error) { + p := common.Pagination{Page: page, PageSize: pageSize} + p.DefaultPagination() + rows, total, err := s.repo.ListAllComments(keyword, showDeleted, p.Offset(), p.PageSize) + if rows == nil { + rows = []model.AdminCommentRow{} + } + return rows, total, err +} + // SearchUsers 搜索LV3+用户(用于@艾特) func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) { rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10) diff --git a/internal/service/repository.go b/internal/service/repository.go index 2d1188b..ea050d9 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -134,6 +134,7 @@ type commentStore interface { Username string }, error) GetUserUIDByID(userID uint) (string, error) + ListAllComments(keyword string, showDeleted bool, offset, limit int) ([]model.AdminCommentRow, int64, error) } // commentNotifier CommentService 所需的通知接口(ISP) diff --git a/templates/MetaLab-2026/html/messages/index.html b/templates/MetaLab-2026/html/messages/index.html index a90461a..3fec2f5 100644 --- a/templates/MetaLab-2026/html/messages/index.html +++ b/templates/MetaLab-2026/html/messages/index.html @@ -41,6 +41,8 @@ {{$link := ""}} {{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)}} {{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 c434002..0510e4b 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -80,7 +80,15 @@ {{if .IsLoggedIn}}
| ID | +作者 | +评论内容 | +所属文章 | +时间 | +操作 | +
|---|