diff --git a/cmd/server/main.go b/cmd/server/main.go index 6f2b918..485e1e2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,7 +27,7 @@ func main() { if err != nil { log.Fatalf("连接数据库失败: %v", err) } - if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}); err != nil { + if err := db.AutoMigrate(&model.User{}, &model.AuditSubmission{}, &model.SiteSetting{}, &model.Notification{}, &model.Post{}, &model.UserCheckIn{}, &model.UserTask{}, &model.EnergyLog{}, &model.PostEnergizeLog{}, &model.DailyExpSummary{}, &model.Comment{}, &model.CommentMention{}); err != nil { log.Fatalf("数据库迁移失败: %v", err) } diff --git a/internal/common/errors.go b/internal/common/errors.go index 43088db..82a10ba 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -52,4 +52,8 @@ var ( ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取") ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限") ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿") + + // 评论相关 + ErrCommentNotFound = errors.New("评论不存在") + ErrCommentEmpty = errors.New("评论内容不能为空") ) diff --git a/internal/controller/comment_controller.go b/internal/controller/comment_controller.go new file mode 100644 index 0000000..edcecb1 --- /dev/null +++ b/internal/controller/comment_controller.go @@ -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) +} diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index e117609..58e710a 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -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 的最小依赖(ISP:6 个方法) +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) +} diff --git a/internal/model/comment.go b/internal/model/comment.go new file mode 100644 index 0000000..36ad642 --- /dev/null +++ b/internal/model/comment.go @@ -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"` // 父评论ID,NULL=顶级评论 + 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"` +} diff --git a/internal/model/notification.go b/internal/model/notification.go index 09f4f0f..f1c025e 100644 --- a/internal/model/notification.go +++ b/internal/model/notification.go @@ -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 消息列表查询结果 diff --git a/internal/model/post.go b/internal/model/post.go index 7184593..61b0e0d 100644 --- a/internal/model/post.go +++ b/internal/model/post.go @@ -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"` diff --git a/internal/repository/comment_repo.go b/internal/repository/comment_repo.go new file mode 100644 index 0000000..e965d72 --- /dev/null +++ b/internal/repository/comment_repo.go @@ -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 +} diff --git a/internal/router/api.go b/internal/router/api.go index bbe22f7..f5881a3 100644 --- a/internal/router/api.go +++ b/internal/router/api.go @@ -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) + } } diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index 652793e..985b7fe 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -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) ( diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 9730b3c..4a4d0d3 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -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, } } diff --git a/internal/service/comment_service.go b/internal/service/comment_service.go new file mode 100644 index 0000000..a6db85c --- /dev/null +++ b/internal/service/comment_service.go @@ -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, + }) + } + } +} diff --git a/internal/service/repository.go b/internal/service/repository.go index fadf170..2d1188b 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -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 +} diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index be2d850..c434002 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -71,6 +71,31 @@ {{end}} + + + {{if .Post.AllowComment}} +
+

评论 0

+ + {{if .IsLoggedIn}} +
+ + +
+ {{else}} +
+ 登录 后参与评论 +
+ {{end}} + +
+ + +
+ {{end}} + + + {{template "layout/footer.html" .}} @@ -219,5 +244,405 @@ }); })(); + + diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index f02836d..5c024b4 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -713,3 +713,241 @@ margin-top: 2px; font-weight: 500; } + +/* ========== 评论区 ========== */ +.comments-section { + max-width: 800px; + margin: 32px auto 0; +} + +.comments-title { + font-size: 18px; + font-weight: 600; + color: #111; + margin: 0 0 16px 0; + padding-bottom: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.comments-count { + color: #6366f1; +} + +.comment-form { + margin-bottom: 24px; +} + +.comment-input { + width: 100%; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + line-height: 1.6; + color: #1f2937; + resize: vertical; + font-family: inherit; + box-sizing: border-box; +} + +.comment-input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.comment-submit-btn { + margin-top: 10px; +} + +.comment-login-hint { + text-align: center; + padding: 24px; + color: #9ca3af; + font-size: 14px; +} + +.comment-login-hint a { + color: #6366f1; + text-decoration: none; +} + +.comments-list { + display: flex; + flex-direction: column; + gap: 0; +} + +/* 单条评论卡片 */ +.comment-card { + padding: 16px 0; + border-bottom: 1px solid #f3f4f6; +} + +.comment-card:last-child { + border-bottom: none; +} + +.comment-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.comment-author { + font-size: 14px; + font-weight: 600; + color: #111; +} + +.comment-time { + font-size: 12px; + color: #9ca3af; +} + +.comment-body { + font-size: 14px; + line-height: 1.7; + color: #374151; + word-break: break-word; + margin-bottom: 8px; +} + +.comment-body .reply-to { + color: #6366f1; + margin-right: 6px; +} + +.comment-actions { + display: flex; + gap: 16px; + align-items: center; +} + +.comment-action-btn { + background: none; + border: none; + font-size: 12px; + color: #9ca3af; + cursor: pointer; + padding: 2px 0; +} + +.comment-action-btn:hover { + color: #6366f1; +} + +/* 回复列表(内联) */ +.comment-replies { + margin-top: 10px; + padding-left: 36px; + border-left: 2px solid #e5e7eb; +} + +.comment-reply-card { + padding: 10px 0 10px 16px; + border-bottom: 1px solid #f9fafb; +} + +.comment-reply-card:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.comment-reply-body { + font-size: 13px; + line-height: 1.6; + color: #4b5563; +} + +.comment-reply-to { + color: #6366f1; + margin-right: 4px; +} + +/* 展开回复按钮 */ +.comment-toggle-replies { + background: none; + border: none; + color: #6366f1; + font-size: 12px; + cursor: pointer; + padding: 4px 0; + margin-top: 4px; +} + +.comment-toggle-replies:hover { + text-decoration: underline; +} + +/* 回复输入框 */ +.comment-reply-form { + display: flex; + gap: 8px; + margin-top: 10px; + padding-left: 36px; +} + +.comment-reply-input { + flex: 1; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + resize: vertical; +} + +.comment-reply-input:focus { + outline: none; + border-color: #6366f1; +} + +.comment-reply-submit { + padding: 6px 14px; + font-size: 13px; + flex-shrink: 0; +} + +/* 加载更多 */ +.comments-load-more { + display: block; + margin: 16px auto 0; +} + +/* @提及下拉 */ +.mention-dropdown { + position: absolute; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0,0,0,0.12); + max-height: 240px; + overflow-y: auto; + z-index: 10000; + min-width: 200px; +} + +.mention-item { + padding: 8px 14px; + font-size: 13px; + cursor: pointer; + color: #374151; +} + +.mention-item:hover, +.mention-item.active { + background: #f5f3ff; + color: #6366f1; +} + +.mention-item .mention-username { + font-weight: 500; +} + +.mention-item .mention-uid { + color: #9ca3af; + font-size: 11px; + margin-left: 8px; +}