feat: 评论系统补充功能完成——图片上传、后台管理、通知跳转高亮
- 评论图片上传(LV4+,经验≥1500),JPEG/PNG 自动转 WebP,二进制搜索质量优化 - 后台评论管理页面,支持关键词搜索、分页、删除,侧边栏新增入口 - 通知跳转高亮:评论通知 RelatedID 改为 postID,消息页链接到 #comments,前端自动滚动 - 新增 adminCommentUseCase/commentStore 接口(遵循 ISP),AdminCommentRow 移至 model 层 - 前端 @mention 联想/图片上传光标插入/评论展开收起/内联回复交互完善
This commit is contained in:
66
internal/controller/admin/admin_comment_controller.go
Normal file
66
internal/controller/admin/admin_comment_controller.go
Normal file
@ -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, "删除成功")
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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})
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) (
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user