feat: 新增站点公告系统与多项体验优化
此提交完成了以下核心功能与优化: 1. 新增站点公告全流程支持:包含公告模型、CRUD接口、管理后台页面、公告详情页与首页展示 2. 实现评论楼层化与回复嵌套:重构评论系统为树状结构,支持回复评论与层级限制 3. 添加用户在线状态追踪:异步更新用户最后活跃时间并做SQL限频 4. 优化用户体验:移动端浮动操作条、统一板块标签样式、用户页签到积分展示、注册即登录优化 5. 其他细节优化:SEO站点地图新增公告页、评论分页与统计、登录/注册表单增强等
This commit is contained in:
@@ -18,27 +18,114 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
||||
return &CommentService{db: db}
|
||||
}
|
||||
|
||||
// ListByPost 获取帖子的评论列表
|
||||
func (s *CommentService) ListByPost(postID uint) ([]model.Comment, error) {
|
||||
var comments []model.Comment
|
||||
err := s.db.Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&comments).Error
|
||||
return comments, err
|
||||
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||
type CommentNode struct {
|
||||
model.Comment
|
||||
Replies []CommentNode `json:"replies,omitempty"`
|
||||
}
|
||||
|
||||
// Create 创建评论
|
||||
func (s *CommentService) Create(userID, postID uint, content string) (*model.Comment, error) {
|
||||
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
|
||||
const maxReplyDepth = 10
|
||||
|
||||
// ListFloorPaged 楼层分页获取帖子评论树:
|
||||
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
|
||||
// 返回:树根列表、楼层数、全部评论数(含回复)。
|
||||
func (s *CommentService) ListFloorPaged(postID uint, page, size int) ([]CommentNode, int64, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
base := s.db.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND status = ?", postID, model.ContentStatusPublished)
|
||||
|
||||
var floors, totalComments int64
|
||||
if err := base.Session(&gorm.Session{}).
|
||||
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// 1. 本页楼层(主评论)
|
||||
var roots []model.Comment
|
||||
if err := base.Session(&gorm.Session{}).
|
||||
Where("parent_id IS NULL").
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&roots).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
if len(roots) == 0 {
|
||||
return []CommentNode{}, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询)
|
||||
rootIDs := make([]uint, len(roots))
|
||||
for i, r := range roots {
|
||||
rootIDs[i] = r.ID
|
||||
}
|
||||
var replies []model.Comment
|
||||
if err := s.db.Where("root_id IN (?) AND status = ?", rootIDs, model.ContentStatusPublished).
|
||||
Order("created_at ASC").
|
||||
Preload("User").
|
||||
Find(&replies).Error; err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
|
||||
// 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
|
||||
children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
|
||||
for _, r := range replies {
|
||||
if r.ParentID != nil {
|
||||
children[*r.ParentID] = append(children[*r.ParentID], r)
|
||||
}
|
||||
}
|
||||
placed := make(map[uint]bool, len(roots)+len(replies))
|
||||
var build func(pid uint) []CommentNode
|
||||
build = func(pid uint) []CommentNode {
|
||||
out := []CommentNode{}
|
||||
for _, c := range children[pid] {
|
||||
out = append(out, CommentNode{Comment: c, Replies: build(c.ID)})
|
||||
placed[c.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
result := make([]CommentNode, len(roots))
|
||||
for i := range roots {
|
||||
result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
|
||||
placed[roots[i].ID] = true
|
||||
}
|
||||
// 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容
|
||||
for _, r := range replies {
|
||||
if placed[r.ID] || r.RootID == nil {
|
||||
continue
|
||||
}
|
||||
for i := range result {
|
||||
if result[i].ID == *r.RootID {
|
||||
result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)
|
||||
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
|
||||
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint) (*model.Comment, *model.Comment, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return nil, errors.New("评论内容不能为空")
|
||||
return nil, nil, errors.New("评论内容不能为空")
|
||||
}
|
||||
|
||||
// 检查帖子是否存在且未锁定评论
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, errors.New("帖子不存在")
|
||||
return nil, nil, errors.New("帖子不存在")
|
||||
}
|
||||
|
||||
comment := &model.Comment{
|
||||
@@ -47,14 +134,36 @@ func (s *CommentService) Create(userID, postID uint, content string) (*model.Com
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
}
|
||||
|
||||
// 子回复:校验父评论(同帖、已发布),继承楼层根与层级
|
||||
var parent *model.Comment
|
||||
if parentID != nil {
|
||||
parent = &model.Comment{}
|
||||
if err := s.db.
|
||||
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
|
||||
First(parent).Error; err != nil {
|
||||
return nil, nil, errors.New("回复的评论不存在")
|
||||
}
|
||||
if parent.Depth >= maxReplyDepth {
|
||||
return nil, nil, errors.New("回复层级过深")
|
||||
}
|
||||
rootID := parent.ID
|
||||
if parent.RootID != nil {
|
||||
rootID = *parent.RootID
|
||||
}
|
||||
comment.ParentID = parentID
|
||||
comment.RootID = &rootID
|
||||
comment.Depth = parent.Depth + 1
|
||||
}
|
||||
|
||||
if err := s.db.Create(comment).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
||||
// 预加载用户
|
||||
s.db.Preload("User").First(comment, comment.ID)
|
||||
return comment, nil
|
||||
return comment, parent, nil
|
||||
}
|
||||
|
||||
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
|
||||
@@ -101,7 +210,7 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 删除评论(仅作者或管理员可操作)
|
||||
// Delete 删除评论(仅作者或管理员可操作),级联软删整棵子树
|
||||
func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
var comment model.Comment
|
||||
if err := s.db.First(&comment, commentID).Error; err != nil {
|
||||
@@ -111,12 +220,38 @@ func (s *CommentService) Delete(commentID, userID uint, role string) error {
|
||||
if comment.UserID != userID && role != RoleAdmin {
|
||||
return errors.New("无权限删除此评论")
|
||||
}
|
||||
// 软删除
|
||||
if err := s.db.Delete(&comment).Error; err != nil {
|
||||
// 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛)
|
||||
var all []model.Comment
|
||||
if comment.RootID != nil {
|
||||
s.db.Where("root_id = ?", *comment.RootID).Find(&all)
|
||||
} else {
|
||||
s.db.Where("root_id = ?", comment.ID).Find(&all)
|
||||
}
|
||||
descendants := map[uint]bool{comment.ID: true}
|
||||
for i := 0; i <= maxReplyDepth; i++ {
|
||||
added := false
|
||||
for _, c := range all {
|
||||
if c.ParentID == nil || descendants[c.ID] {
|
||||
continue
|
||||
}
|
||||
if descendants[*c.ParentID] {
|
||||
descendants[c.ID] = true
|
||||
added = true
|
||||
}
|
||||
}
|
||||
if !added {
|
||||
break
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(descendants))
|
||||
for id := range descendants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// 软删子树并按实际条数递减帖子评论数
|
||||
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新帖子评论数
|
||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", len(ids)))
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user