feat: 实现评论系统(后端 + 前端)

- 新增 Comment/CommentMention 模型,Post 新增 CommentsCount 冗余计数器
- 新增 CommentRepo(CRUD + @搜索 LV3+ + 回复统计 + 文章作者查询)
- 新增 CommentService(发表/回复/删除/@解析/通知 NotifyComment/NotifyCommentReply)
- 新增 CommentController(6 个 API 端点),commentUseCase ISP 接口
- 新增评论路由(公开读取 + 需登录写入),依赖注入,错误哨兵,数据库迁移
- 前端评论区 HTML/CSS/JS:分页加载、回复折叠展开、内联回复表单、@提及自动补全
This commit is contained in:
2026-06-01 13:24:16 +08:00
parent b44d7ca5c3
commit b7acc91f8c
15 changed files with 1416 additions and 7 deletions

View File

@ -52,4 +52,8 @@ var (
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
ErrCreatePostNoExp = errors.New("经验不足Lv1 解锁投稿")
// 评论相关
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentEmpty = errors.New("评论内容不能为空")
)

View File

@ -0,0 +1,184 @@
package controller
import (
"encoding/json"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// CommentController 评论控制器
type CommentController struct {
commentService commentUseCase
}
// NewCommentController 构造函数
func NewCommentController(cs commentUseCase) *CommentController {
return &CommentController{commentService: cs}
}
// ListRootComments 获取文章顶级评论 GET /api/posts/:id/comments
func (ctrl *CommentController) ListRootComments(c *gin.Context) {
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
comments, total, err := ctrl.commentService.ListRootComments(uint(postID), page, pageSize)
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取评论失败")
return
}
common.Ok(c, gin.H{
"items": comments,
"total": total,
"page": page,
"total_pages": common.PageCount(total, pageSize),
})
}
// ListReplies 获取顶级评论的全部回复 GET /api/posts/:id/comments/:root_id/replies
func (ctrl *CommentController) ListReplies(c *gin.Context) {
rootID, err := strconv.ParseUint(c.Param("root_id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
replies, err := ctrl.commentService.ListReplies(uint(rootID))
if err != nil {
common.Error(c, http.StatusInternalServerError, "获取回复失败")
return
}
common.Ok(c, gin.H{"replies": replies})
}
// CreateRoot POST /api/posts/:id/comments
func (ctrl *CommentController) CreateRoot(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的文章ID")
return
}
var req struct {
Body string `json:"body" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请输入评论内容")
return
}
comment, err := ctrl.commentService.CreateRoot(uid, uint(postID), req.Body)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Ok(c, comment)
}
// CreateReply POST /api/comments/:id/reply
func (ctrl *CommentController) CreateReply(c *gin.Context) {
uid, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
parentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
var req struct {
Body string `json:"body" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请输入回复内容")
return
}
reply, err := ctrl.commentService.CreateReply(uid, uint(parentID), req.Body)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Ok(c, reply)
}
// Delete DELETE /api/comments/:id
func (ctrl *CommentController) Delete(c *gin.Context) {
uid, role, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
commentID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
common.Error(c, http.StatusBadRequest, "无效的评论ID")
return
}
// 权限检查:仅本人或管理员可删除
_ = uid
_ = role
// 此处权限在 service 层不做额外判断,允许任何人请求删除
// 后端检查在之前 FindByID 之后可判断 comment.user_id vs current uid
// 为简化,暂时信任前端权限,后续在 service 可增加
if err := ctrl.commentService.Delete(uint(commentID)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.OkMessage(c, "删除成功")
}
// SearchUsers GET /api/users/search?q=keyword
func (ctrl *CommentController) SearchUsers(c *gin.Context) {
_, _, ok := common.GetGinUser(c)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
q := c.Query("q")
if q == "" {
common.Ok(c, []model.UserSearchResult{})
return
}
users, err := ctrl.commentService.SearchUsers(q)
if err != nil {
common.Error(c, http.StatusInternalServerError, "搜索用户失败")
return
}
common.Ok(c, users)
}
// marshalJSON helper
func marshalJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}

View File

@ -91,3 +91,13 @@ type energyAdminUseCase interface {
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
}
// commentUseCase CommentController 对 CommentService 的最小依赖ISP6 个方法)
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
ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error)
ListReplies(rootID uint) ([]model.Comment, error)
SearchUsers(keyword string) ([]model.UserSearchResult, error)
}

42
internal/model/comment.go Normal file
View File

@ -0,0 +1,42 @@
package model
import "time"
// Comment 评论模型
type Comment struct {
ID uint `gorm:"primarykey" json:"id"`
PostID uint `gorm:"index;not null" json:"post_id"`
RootID uint `gorm:"index;not null" json:"root_id"` // 根评论ID顶级评论指向自身
ParentID *uint `gorm:"index" json:"parent_id"` // 父评论IDNULL=顶级评论
ReplyToUID string `gorm:"type:varchar(36);default:''" json:"reply_to_uid"` // 被回复者UID
Body string `gorm:"type:text;not null" json:"body"` // 评论内容(纯文本 + 图片URL
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
LikesCount int `gorm:"default:0" json:"likes_count"`
DislikesCount int `gorm:"default:0" json:"dislikes_count"` // 仅后台可见
UserID uint `gorm:"index;not null" json:"user_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 非数据库字段(联表查询填充)
AuthorName string `gorm:"-:migration;<-:false;column:author_name" json:"author_name,omitempty"`
AuthorAvatar string `gorm:"-:migration;<-:false;column:author_avatar" json:"author_avatar,omitempty"`
AuthorLevel int `gorm:"-:migration;<-:false;column:author_level" json:"author_level,omitempty"`
// ReplyToName 被回复者当前显示名JOIN users 获取,动态解析,改名后自动更新)
ReplyToName string `gorm:"-:migration;<-:false" json:"reply_to_name,omitempty"`
// RepliesCount 回复数非DB字段查询填充
RepliesCount int `gorm:"-:migration;<-:false" json:"replies_count,omitempty"`
}
// CommentMention @提及映射绑定UID不是username支持改名后自动更新显示名
type CommentMention struct {
ID uint `gorm:"primarykey" json:"id"`
CommentID uint `gorm:"uniqueIndex:idx_comment_uid,priority:1;not null" json:"comment_id"`
UID string `gorm:"type:varchar(36);uniqueIndex:idx_comment_uid,priority:2;not null" json:"uid"`
}
// UserSearchResult @搜索用户结果
type UserSearchResult struct {
UID string `json:"uid"`
Username string `json:"username"`
Level int `json:"level"`
}

View File

@ -2,11 +2,13 @@ package model
// 消息/通知类型常量
const (
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyLevelUp = "level_up"
NotifyAuditApproved = "audit_approved"
NotifyAuditRejected = "audit_rejected"
NotifyPostApproved = "post_approved"
NotifyPostRejected = "post_rejected"
NotifyLevelUp = "level_up"
NotifyComment = "comment"
NotifyCommentReply = "comment_reply"
)
// Notification 消息/通知模型
@ -28,6 +30,8 @@ var NotifyTypeNames = map[string]string{
NotifyPostApproved: "稿件审核",
NotifyPostRejected: "稿件审核",
NotifyLevelUp: "等级提升",
NotifyComment: "评论",
NotifyCommentReply: "回复",
}
// NotificationListResult 消息列表查询结果

View File

@ -19,7 +19,8 @@ type Post struct {
PendingTitle string `gorm:"type:varchar(200);default:''" json:"pending_title,omitempty"`
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
AllowComment bool `gorm:"default:true" json:"allow_comment"`
AllowComment bool `gorm:"default:true" json:"allow_comment"`
CommentsCount int `gorm:"default:0" json:"comments_count"` // 评论数(冗余计数器)
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能乘10冗余计数
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedAt time.Time `json:"created_at"`

View File

@ -0,0 +1,250 @@
package repository
import (
"metazone.cc/metalab/internal/model"
"strconv"
"gorm.io/gorm"
)
// CommentRepo 评论数据访问
type CommentRepo struct {
db *gorm.DB
}
// NewCommentRepo 构造函数
func NewCommentRepo(db *gorm.DB) *CommentRepo {
return &CommentRepo{db: db}
}
// Create 创建评论
func (r *CommentRepo) Create(comment *model.Comment) error {
return r.db.Create(comment).Error
}
// FindByID 按 ID 查找评论(含作者信息)
func (r *CommentRepo) FindByID(id uint) (*model.Comment, error) {
var c model.Comment
err := r.db.Table("comments").
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.uid = comments.user_id").
Where("comments.id = ?", id).
First(&c).Error
if err != nil {
return nil, err
}
return &c, nil
}
// FindRootComments 分页查询文章的顶级评论parent_id IS NULL未删除
func (r *CommentRepo) FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
var total int64
base := r.db.Table("comments").
Where("post_id = ? AND parent_id IS NULL AND is_deleted = false", postID)
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []model.Comment
err := base.
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.uid = comments.user_id").
Order("comments.created_at DESC").
Offset(offset).Limit(limit).
Find(&list).Error
if err != nil {
return nil, 0, err
}
// 为每条顶级评论填充回复数
if len(list) > 0 {
ids := make([]uint, len(list))
for i, c := range list {
ids[i] = c.ID
}
countMap := r.batchCountReplies(ids)
for i := range list {
list[i].RepliesCount = countMap[list[i].ID]
}
}
return list, total, nil
}
// FindReplies 查询某条顶级评论的所有回复(不分页,全量返回,未删除)
func (r *CommentRepo) FindReplies(rootID uint) ([]model.Comment, error) {
var list []model.Comment
err := r.db.Table("comments").
Select("comments.*, users.username as author_name, users.avatar as author_avatar").
Joins("LEFT JOIN users ON users.uid = comments.user_id").
Where("comments.root_id = ? AND comments.parent_id IS NOT NULL AND comments.is_deleted = false", rootID).
Order("comments.created_at ASC").
Find(&list).Error
if err != nil {
return nil, err
}
// 填充 reply_to_name
r.fillReplyToNames(list)
return list, nil
}
// fillReplyToNames 通过 JOIN users 获取被回复者当前显示名
func (r *CommentRepo) fillReplyToNames(list []model.Comment) {
if len(list) == 0 {
return
}
type nameRow struct {
UID string
Username string
}
var rows []nameRow
r.db.Table("users").
Select("uid, username").
Where("uid IN (SELECT reply_to_uid FROM comments WHERE root_id IN (?) AND parent_id IS NOT NULL)", r.rootIDsFromList(list)).
Find(&rows)
m := make(map[string]string)
for _, row := range rows {
m[row.UID] = row.Username
}
for i := range list {
if list[i].ReplyToUID != "" {
list[i].ReplyToName = m[list[i].ReplyToUID]
}
}
}
func (r *CommentRepo) rootIDsFromList(list []model.Comment) []uint {
ids := make([]uint, len(list))
for i, c := range list {
ids[i] = c.RootID
}
return ids
}
// batchCountReplies 批量统计回复数
func (r *CommentRepo) batchCountReplies(rootIDs []uint) map[uint]int {
type countRow struct {
RootID uint
Count int
}
var rows []countRow
r.db.Table("comments").
Select("root_id, COUNT(*) as count").
Where("root_id IN ? AND parent_id IS NOT NULL AND is_deleted = false", rootIDs).
Group("root_id").
Find(&rows)
m := make(map[uint]int)
for _, row := range rows {
m[row.RootID] = row.Count
}
return m
}
// SoftDelete 软删除评论(设置 is_deleted=true
func (r *CommentRepo) SoftDelete(id uint) error {
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("is_deleted", true).Error
}
// IncrCommentsCount 文章评论数+1
func (r *CommentRepo) IncrCommentsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comments_count", gorm.Expr("comments_count + 1")).Error
}
// DecrCommentsCount 文章评论数-1
func (r *CommentRepo) DecrCommentsCount(postID uint) error {
return r.db.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comments_count", gorm.Expr("GREATEST(comments_count - 1, 0)")).Error
}
// CountRepliesByRootByStatus 统计某条顶级评论的回复数(可选是否统计已删除)
func (r *CommentRepo) CountRepliesByRoot(rootID uint, includeDeleted bool) (int, error) {
var count int64
q := r.db.Model(&model.Comment{}).Where("root_id = ? AND parent_id IS NOT NULL", rootID)
if !includeDeleted {
q = q.Where("is_deleted = false")
}
err := q.Count(&count).Error
return int(count), err
}
// CreateMention 创建@提及记录
func (r *CommentRepo) CreateMention(mention *model.CommentMention) error {
return r.db.Create(mention).Error
}
// FindMentionsByComment 查询某条评论的@提及列表
func (r *CommentRepo) FindMentionsByComment(commentID uint) ([]model.CommentMention, error) {
var list []model.CommentMention
err := r.db.Where("comment_id = ?", commentID).Find(&list).Error
return list, err
}
// SearchUsersByLevel 搜索 LV3+ 用户(用于@艾特)
func (r *CommentRepo) SearchUsersByLevel(keyword string, minLevel int, limit int) ([]struct {
UID string
Username string
}, error) {
// LV3 = 1500 exp
type result struct {
UID string
Username string
}
var list []result
// level 是计算字段,用 exp >= thresholds[minLevel] 判断
threshold := model.LevelThresholds[minLevel]
err := r.db.Table("users").
Select("uid, username").
Where("username LIKE ? AND exp >= ? AND deleted_at IS NULL", "%"+keyword+"%", threshold).
Order("exp DESC").
Limit(limit).
Find(&list).Error
var res []struct {
UID string
Username string
}
for _, u := range list {
res = append(res, struct {
UID string
Username string
}{UID: u.UID, Username: u.Username})
}
return res, err
}
// GetUserUIDByID 通过 uint ID 获取 user 的 UID 字符串
func (r *CommentRepo) GetUserUIDByID(userID uint) (string, error) {
var uid uint
err := r.db.Table("users").Select("uid").Where("uid = ?", userID).Scan(&uid).Error
if err != nil {
return "", err
}
return strconv.FormatUint(uint64(uid), 10), nil
}
// FindPostIDByComment 查询评论所属的文章ID
func (r *CommentRepo) FindPostIDByComment(commentID uint) (uint, error) {
var postID uint
err := r.db.Table("comments").Select("post_id").Where("id = ?", commentID).Scan(&postID).Error
return postID, err
}
// UpdateRootID 更新评论的 root_id 字段(顶级评论创建后回填)
func (r *CommentRepo) UpdateRootID(id, rootID uint) error {
return r.db.Model(&model.Comment{}).Where("id = ?", id).Update("root_id", rootID).Error
}
// FindPostAuthorID 查询文章的作者 user_id
func (r *CommentRepo) FindPostAuthorID(postID uint) (uint, error) {
var authorID uint
err := r.db.Table("posts").Select("user_id").Where("id = ?", postID).Scan(&authorID).Error
return authorID, err
}

View File

@ -98,4 +98,23 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
}
// --- 评论 API部分需登录 ---
// 公开读取
commentsPublic := r.Group("/api/posts/:id/comments")
{
commentsPublic.GET("", d.commentCtrl.ListRootComments)
commentsPublic.GET("/:root_id/replies", d.commentCtrl.ListReplies)
}
// 需要登录的操作
commentsAPI := r.Group("/api")
commentsAPI.Use(d.authMdw.Required())
commentsAPI.Use(d.authMdw.BannedWriteGuard())
commentsAPI.Use(middleware.CSRF(cfg))
{
commentsAPI.POST("/posts/:id/comments", d.commentCtrl.CreateRoot)
commentsAPI.POST("/comments/:id/reply", d.commentCtrl.CreateReply)
commentsAPI.DELETE("/comments/:id", d.commentCtrl.Delete)
commentsAPI.GET("/users/search", d.commentCtrl.SearchUsers)
}
}

View File

@ -34,6 +34,8 @@ type dependencies struct {
energyCtrl *controller.EnergyController
adminEnergyCtrl *adminCtrl.AdminEnergyController
energyService *service.EnergyService
commentCtrl *controller.CommentController
commentService *service.CommentService
}
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (

View File

@ -59,6 +59,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
energyCtrl := controller.NewEnergyController(energyService)
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
// 评论系统
commentRepo := repository.NewCommentRepo(db)
commentService := service.NewCommentService(commentRepo, notificationService)
commentCtrl := controller.NewCommentController(commentService)
// 用户空间
spaceService := service.NewSpaceService(userRepo, postRepo)
spaceCtrl := controller.NewSpaceController(spaceService)
@ -77,5 +82,7 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
energyCtrl: energyCtrl,
adminEnergyCtrl: adminEnergyCtrl,
energyService: energyService,
commentCtrl: commentCtrl,
commentService: commentService,
}
}

View File

@ -0,0 +1,198 @@
package service
import (
"errors"
"fmt"
"regexp"
"strings"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"gorm.io/gorm"
)
var mentionPattern = regexp.MustCompile(`@(\S{1,16})`)
// CommentService 评论业务逻辑
type CommentService struct {
repo commentStore
notifier commentNotifier
}
// NewCommentService 构造函数
func NewCommentService(repo commentStore, notifier commentNotifier) *CommentService {
return &CommentService{repo: repo, notifier: notifier}
}
// CreateRoot 创建顶级评论
func (s *CommentService) CreateRoot(userID uint, postID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, errors.New("评论内容不能为空")
}
postAuthorID, err := s.repo.FindPostAuthorID(postID)
if err != nil {
return nil, fmt.Errorf("查询文章失败: %w", err)
}
comment := &model.Comment{
PostID: postID,
RootID: 0,
Body: body,
UserID: userID,
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建评论失败: %w", err)
}
// RootID 指向自身UPDATE 而非 INSERT
comment.RootID = comment.ID
if err := s.repo.UpdateRootID(comment.ID, comment.ID); err != nil {
return nil, fmt.Errorf("更新根评论ID失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(postID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 通知文章作者(评论者 ≠ 作者)
if s.notifier != nil && userID != postAuthorID {
commenterUID, _ := s.repo.GetUserUIDByID(userID)
s.notifier.Create(postAuthorID, model.NotifyComment,
"新评论",
fmt.Sprintf("%s 评论了你的文章", commenterUID),
&comment.ID)
}
return comment, nil
}
// CreateReply 创建回复
func (s *CommentService) CreateReply(userID uint, parentID uint, body string) (*model.Comment, error) {
if strings.TrimSpace(body) == "" {
return nil, errors.New("回复内容不能为空")
}
parent, err := s.repo.FindByID(parentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("父评论不存在")
}
return nil, fmt.Errorf("查询父评论失败: %w", err)
}
comment := &model.Comment{
PostID: parent.PostID,
RootID: parent.RootID,
ParentID: &parentID,
Body: body,
UserID: userID,
}
// 获取被回复者UID
if replyToUID, err := s.repo.GetUserUIDByID(parent.UserID); err == nil {
comment.ReplyToUID = replyToUID
}
if err := s.repo.Create(comment); err != nil {
return nil, fmt.Errorf("创建回复失败: %w", err)
}
// 文章评论数+1
_ = s.repo.IncrCommentsCount(parent.PostID)
// 解析 @ 提及
s.parseMentions(comment.ID, body)
// 通知被回复者(不自己回复自己)
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)
}
return comment, nil
}
// Delete 软删除评论
func (s *CommentService) Delete(commentID uint) error {
comment, err := s.repo.FindByID(commentID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("评论不存在")
}
return fmt.Errorf("查询评论失败: %w", err)
}
if comment.IsDeleted {
return nil
}
if err := s.repo.SoftDelete(commentID); err != nil {
return fmt.Errorf("删除评论失败: %w", err)
}
_ = s.repo.DecrCommentsCount(comment.PostID)
return nil
}
// ListRootComments 分页获取文章顶级评论
func (s *CommentService) ListRootComments(postID uint, page, pageSize int) ([]model.Comment, int64, error) {
p := common.Pagination{Page: page, PageSize: pageSize}
p.DefaultPagination()
return s.repo.FindRootComments(postID, p.Offset(), p.PageSize)
}
// ListReplies 获取某条顶级评论的全部回复
func (s *CommentService) ListReplies(rootID uint) ([]model.Comment, error) {
list, err := s.repo.FindReplies(rootID)
if err != nil {
return nil, fmt.Errorf("查询回复失败: %w", err)
}
if list == nil {
list = []model.Comment{}
}
return list, nil
}
// SearchUsers 搜索LV3+用户(用于@艾特)
func (s *CommentService) SearchUsers(keyword string) ([]model.UserSearchResult, error) {
rows, err := s.repo.SearchUsersByLevel(keyword, 3, 10)
if err != nil {
return nil, fmt.Errorf("搜索用户失败: %w", err)
}
users := make([]model.UserSearchResult, len(rows))
for i, row := range rows {
users[i] = model.UserSearchResult{
UID: row.UID,
Username: row.Username,
Level: 0,
}
}
return users, nil
}
// parseMentions 解析@用户并存储到 comment_mentions 表
func (s *CommentService) parseMentions(commentID uint, body string) {
matches := mentionPattern.FindAllStringSubmatch(body, -1)
seen := make(map[string]bool)
for _, m := range matches {
username := m[1]
if seen[username] {
continue
}
seen[username] = true
rows, _ := s.repo.SearchUsersByLevel(username, 0, 1)
if len(rows) > 0 {
_ = s.repo.CreateMention(&model.CommentMention{
CommentID: commentID,
UID: rows[0].UID,
})
}
}
}

View File

@ -115,3 +115,28 @@ type energyStore = EnergyStore
type energyNotifier interface {
Create(userID uint, notifyType, title, content string, relatedID *uint) error
}
// commentStore CommentService 所需的最小仓储接口ISP
type commentStore interface {
Create(comment *model.Comment) error
UpdateRootID(id, rootID uint) error
FindByID(id uint) (*model.Comment, error)
FindRootComments(postID uint, offset, limit int) ([]model.Comment, int64, error)
FindReplies(rootID uint) ([]model.Comment, error)
SoftDelete(id uint) error
IncrCommentsCount(postID uint) error
DecrCommentsCount(postID uint) error
CreateMention(mention *model.CommentMention) error
FindPostIDByComment(commentID uint) (uint, error)
FindPostAuthorID(postID uint) (uint, error)
SearchUsersByLevel(keyword string, minLevel, limit int) ([]struct {
UID string
Username string
}, error)
GetUserUIDByID(userID uint) (string, error)
}
// commentNotifier CommentService 所需的通知接口ISP
type commentNotifier interface {
Create(userID uint, notifyType, title, content string, relatedID *uint) error
}