GoWind 开源生态GoWind 开源生态
首页
框架
GoWind Admin
GoWind CMS
GoWind IM
GoWind UBA
GoWind IoT
GoWind Toolkit
GoWind Quant
GitHub
首页
框架
GoWind Admin
GoWind CMS
GoWind IM
GoWind UBA
GoWind IoT
GoWind Toolkit
GoWind Quant
GitHub
  • 介绍

    • GoWind CMS 产品介绍
    • GoWind CMS 安装指南
  • 后端文档

    • CMS 后端架构总览
    • CMS 后端模块总览
    • CMS Protobuf API 定义
    • CMS 配置与部署指南
    • 后端扩展机制指南
  • 前端文档

    • CMS 前端架构
    • CMS 前端模块总览
  • 入门教程

    • 新增内容类型全栈实战教程
    • 多端 API 客户端代码生成教程
  • 核心教程

    • 内容多语言翻译实战教程
    • 内容发布工作流实战教程
    • 区块编辑器实战教程
    • Headless API 对接多端实战教程
    • 前台应用开发实战教程
  • 进阶教程

    • 多站点管理实战教程
    • 媒体资源管理实战教程
    • 评论系统实战教程
    • 事件总线架构实战教程
    • Lua 脚本扩展实战教程
  • 高阶教程

    • 权限系统实战教程
    • 双端登录安全实战教程
    • 加密与安全工具实战教程
    • 全文搜索实战教程
    • 任务调度实战教程
    • 实时消息推送实战教程
    • 性能监控实战教程
    • 字典管理系统实战教程
  • 综合教程

    • 全栈集成实战教程
    • 三服务部署实战教程

评论系统实战教程

GoWind CMS 内置完整的评论系统,支持评论审核、多级回复、屏蔽和管理。本教程讲解评论系统的架构设计、数据模型、前台互动与管理后台审核的完整实现。

前置条件

  • 已阅读 CMS 后端架构总览
  • 了解 App API 白名单机制(参见 Headless API 对接多端实战)

一、评论系统架构

1.1 功能概览

1.2 评论状态流转

状态前台可见管理后台说明
PENDING(待审核)否可见新评论默认状态
APPROVED(已通过)是可见审核通过,前台展示
REJECTED(已拒绝)否可见审核拒绝
HIDDEN(已隐藏)否可见临时隐藏

如果站点配置关闭了审核功能,新评论直接为 APPROVED 状态。

二、数据模型

2.1 Comment 实体

// comment/service/v1/comment.proto

message Comment {
  optional uint32 id = 1;
  optional uint32 post_id = 2 [json_name = "postId"];       // 关联帖子
  optional uint32 user_id = 3 [json_name = "userId"];       // 评论用户
  optional string content = 4;                               // 评论内容
  optional uint32 parent_id = 5 [json_name = "parentId"];   // 父评论ID(多级回复)
  optional Status status = 6;
  enum Status {
    COMMENT_STATUS_PENDING = 0;
    COMMENT_STATUS_APPROVED = 1;
    COMMENT_STATUS_REJECTED = 2;
    COMMENT_STATUS_HIDDEN = 3;
  }

  // 扩展字段(查询时关联填充)
  optional string user_name = 10 [json_name = "userName"];
  optional string user_avatar = 11 [json_name = "userAvatar"];
  optional uint32 reply_count = 12 [json_name = "replyCount"];
  repeated Comment replies = 13 [json_name = "replies"];    // 子回复

  optional google.protobuf.Timestamp created_at = 100;
  optional google.protobuf.Timestamp updated_at = 101;
}

2.2 Ent Schema

// app/core/service/internal/data/ent/schema/comment.go
type Comment struct{ ent.Schema }

func (Comment) Mixin() []ent.Mixin {
    return []ent.Mixin{mixin.Time{}}
}

func (Comment) Fields() []ent.Field {
    return []ent.Field{
        field.Uint32("post_id"),
        field.Uint32("user_id"),
        field.Text("content"),
        field.Uint32("parent_id").Optional(),  // 自引用(父评论)
        field.Int("status").Default(0),         // PENDING
    }
}

func (Comment) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("post", Post.Type).Ref("comments").Unique().Required().Field("post_id"),
        edge.From("user", User.Type).Ref("comments").Unique().Field("user_id"),
    }
}

2.3 评论树结构

帖子 #42
├── 评论 #1(顶级评论,user=张三)
│   ├── 评论 #3(回复 #1,user=李四)
│   │   └── 评论 #5(回复 #3,user=张三)
│   └── 评论 #4(回复 #1,user=王五)
├── 评论 #2(顶级评论,user=赵六)
│   └── 评论 #6(回复 #2,user=张三)

三、Admin API 接口

3.1 评论管理接口

// admin/service/v1/i_comment.proto
service CommentService {
  rpc List(pagination.PagingRequest) returns (ListCommentResponse) {
    option (google.api.http) = { get: "/admin/v1/comments" };
  }
  rpc Get(GetCommentRequest) returns (Comment) {
    option (google.api.http) = { get: "/admin/v1/comments/{id}" };
  }
  rpc Delete(DeleteCommentRequest) returns (google.protobuf.Empty) {
    option (google.api.http) = { delete: "/admin/v1/comments/{id}" };
  }
  rpc Approve(ApproveCommentRequest) returns (Comment) {
    option (google.api.http) = { put: "/admin/v1/comments/{id}/approve" };
  }
  rpc Reject(RejectCommentRequest) returns (Comment) {
    option (google.api.http) = { put: "/admin/v1/comments/{id}/reject" };
  }
  rpc Hide(HideCommentRequest) returns (Comment) {
    option (google.api.http) = { put: "/admin/v1/comments/{id}/hide" };
  }
}

3.2 管理后台筛选

# 按状态筛选评论
GET /admin/v1/comments?status=PENDING&page=1&pageSize=20

# 按帖子筛选
GET /admin/v1/comments?postId=42

# 按用户筛选
GET /admin/v1/comments?userId=10

# 时间范围筛选
GET /admin/v1/comments?startTime=2025-01-01&endTime=2025-01-31

四、App API 接口

4.1 前台评论接口

// app/service/v1/i_comment.proto
service CommentService {
  // 浏览评论(免登录)
  rpc List(pagination.PagingRequest) returns (ListCommentResponse) {
    option (google.api.http) = { get: "/app/v1/comments" };
  }

  // 发表评论(需登录)
  rpc Create(CreateCommentRequest) returns (Comment) {
    option (google.api.http) = { post: "/app/v1/comments" body: "*" };
  }
}

4.2 前台发表评论

POST /app/v1/comments
Authorization: Bearer {user_token}

{
  "postId": 42,
  "content": "这篇文章写得非常好!",
  "parentId": null
}

回复评论:

POST /app/v1/comments
Authorization: Bearer {user_token}

{
  "postId": 42,
  "content": "谢谢支持!",
  "parentId": 1
}

五、Repository 实现

5.1 评论树查询

// app/core/service/internal/data/comment_repo.go
func (r *CommentRepo) ListByPost(
    ctx context.Context, postId uint32, page, pageSize int,
) (*commentV1.ListCommentResponse, error) {
    // 1. 查询顶级评论(parent_id 为空)
    query := r.data.db.Comment.Query().
        Where(
            comment.PostIDEQ(postId),
            comment.StatusEQ(int(commentV1.Comment_COMMENT_STATUS_APPROVED)),
            comment.Not(comment.HasParent()),
        ).
        Order(ent.Desc("created_at"))

    total, err := query.Count(ctx)
    if err != nil {
        return nil, err
    }

    entities, err := query.
        Offset((page - 1) * pageSize).
        Limit(pageSize).
        WithUser().           // 预加载评论用户
        WithReplies().        // 预加载一级回复
        All(ctx)

    // 2. 转换为 Protobuf 并构建评论树
    items := make([]*commentV1.Comment, len(entities))
    for i, entity := range entities {
        items[i] = entity2Proto(entity)
        // 递归加载子回复
        items[i].Replies = r.loadReplies(ctx, entity.ID)
    }

    return &commentV1.ListCommentResponse{
        Items: items,
        Total: uint64(total),
    }, nil
}

func (r *CommentRepo) loadReplies(ctx context.Context, parentId uint32) []*commentV1.Comment {
    replies, err := r.data.db.Comment.Query().
        Where(
            comment.ParentIDEQ(parentId),
            comment.StatusEQ(int(commentV1.Comment_COMMENT_STATUS_APPROVED)),
        ).
        Order(ent.Asc("created_at")).
        WithUser().
        All(ctx)
    if err != nil {
        return nil
    }

    result := make([]*commentV1.Comment, len(replies))
    for i, reply := range replies {
        result[i] = entity2Proto(reply)
        // 递归加载子回复
        result[i].Replies = r.loadReplies(ctx, reply.ID)
    }
    return result
}

5.2 创建评论

func (r *CommentRepo) Create(
    ctx context.Context, req *commentV1.CreateCommentRequest,
) (*commentV1.Comment, error) {
    builder := r.data.db.Comment.Create().
        SetPostID(req.Data.GetPostId()).
        SetUserID(req.Data.GetUserId()).
        SetContent(req.Data.GetContent()).
        SetStatus(int(commentV1.Comment_COMMENT_STATUS_PENDING))  // 默认待审核

    if parentId := req.Data.GetParentId(); parentId > 0 {
        builder.SetParentID(parentId)
    }

    entity, err := builder.Save(ctx)
    if err != nil {
        return nil, err
    }
    return entity2Proto(entity), nil
}

六、管理后台实现

6.1 评论审核页面

<!-- views/content/comment/audit.vue -->
<script setup lang="ts">
import { useListComments, useApproveComment, useRejectComment } from '#/api/composables/comment';

const filterStatus = ref('PENDING');
const pagination = reactive(new PaginationQuery());
const { data, isLoading, refetch } = useListComments({
  ...pagination,
  status: filterStatus,
});
const approveMutation = useApproveComment();
const rejectMutation = useRejectComment();

const handleApprove = (id: number) => {
  approveMutation.mutate(id, {
    onSuccess: () => refetch(),
  });
};

const handleReject = (id: number) => {
  rejectMutation.mutate(id, {
    onSuccess: () => refetch(),
  });
};
</script>

<template>
  <Page title="评论审核">
    <RadioGroup v-model:value="filterStatus" button-style="solid">
      <RadioButton value="PENDING">待审核</RadioButton>
      <RadioButton value="APPROVED">已通过</RadioButton>
      <RadioButton value="REJECTED">已拒绝</RadioButton>
      <RadioButton value="HIDDEN">已隐藏</RadioButton>
      <RadioButton value="">全部</RadioButton>
    </RadioGroup>

    <List :loading="isLoading" :data-source="data?.items">
      <template #renderItem="{ item }">
        <List.Item>
          <List.Item.Meta>
            <template #avatar><Avatar :src="item.userAvatar" /></template>
            <template #title>{{ item.userName }}</template>
            <template #description>
              <div>{{ item.content }}</div>
              <small>{{ formatTime(item.createdAt) }}</small>
            </template>
          </List.Item.Meta>
          <template #actions>
            <Button v-if="item.status === 'PENDING'" type="primary" size="small" @click="handleApprove(item.id)">通过</Button>
            <Button v-if="item.status === 'PENDING'" danger size="small" @click="handleReject(item.id)">拒绝</Button>
            <Button danger size="small" @click="handleDelete(item.id)">删除</Button>
          </template>
        </List.Item>
      </template>
    </List>
  </Page>
</template>

七、前台评论组件

7.1 React 评论组件

// src/components/post/CommentSection.tsx
'use client';

import { useState, useEffect } from 'react';
import { useAuthStore } from '@/stores/auth';
import { apiClient } from '@/lib/api/client';

interface Comment {
  id: number;
  content: string;
  userName: string;
  userAvatar: string;
  createdAt: string;
  replies?: Comment[];
  parentId?: number;
}

export function CommentSection({ postId }: { postId: number }) {
  const [comments, setComments] = useState<Comment[]>([]);
  const [content, setContent] = useState('');
  const [replyTo, setReplyTo] = useState<number | null>(null);
  const { isAuthenticated } = useAuthStore();

  useEffect(() => {
    apiClient.get('/comments', { params: { postId } }).then((res) => {
      setComments(res.items);
    });
  }, [postId]);

  const handleSubmit = async () => {
    if (!content.trim()) return;

    try {
      const newComment = await apiClient.post('/comments', {
        postId,
        content,
        parentId: replyTo,
      });
      setComments([...comments, newComment]);
      setContent('');
      setReplyTo(null);
    } catch (error) {
      alert('发表评论失败,请先登录');
    }
  };

  return (
    <section className="mt-12">
      <h2 className="text-2xl font-bold mb-6">评论({comments.length})</h2>

      {/* 评论输入 */}
      <div className="mb-8">
        {replyTo && (
          <div className="text-sm text-gray-500 mb-2">
            回复评论 #{replyTo}
            <button onClick={() => setReplyTo(null)} className="ml-2 text-red-500">取消</button>
          </div>
        )}
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder={isAuthenticated ? '写下你的评论...' : '请先登录'}
          className="w-full p-3 border rounded"
          rows={3}
        />
        <button
          onClick={handleSubmit}
          disabled={!isAuthenticated}
          className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          发表评论
        </button>
      </div>

      {/* 评论列表 */}
      <div className="space-y-6">
        {comments.map((comment) => (
          <CommentItem
            key={comment.id}
            comment={comment}
            onReply={(id) => { setReplyTo(id); setContent(''); }}
          />
        ))}
      </div>
    </section>
  );
}

function CommentItem({
  comment,
  onReply,
}: {
  comment: Comment;
  onReply: (id: number) => void;
}) {
  return (
    <div>
      <div className="flex gap-3">
        <img src={comment.userAvatar || '/default-avatar.png'} className="w-10 h-10 rounded-full" />
        <div className="flex-1">
          <div className="flex items-center gap-2">
            <span className="font-medium">{comment.userName}</span>
            <time className="text-sm text-gray-400">{comment.createdAt}</time>
          </div>
          <p className="mt-1">{comment.content}</p>
          <button onClick={() => onReply(comment.id)} className="text-sm text-blue-500 mt-1">回复</button>

          {/* 子回复 */}
          {comment.replies && comment.replies.length > 0 && (
            <div className="mt-4 pl-4 border-l-2 space-y-4">
              {comment.replies.map((reply) => (
                <CommentItem key={reply.id} comment={reply} onReply={onReply} />
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

八、最佳实践

8.1 防垃圾评论

策略实现
频率限制每用户每分钟最多 5 条评论
敏感词过滤发布前检查敏感词
IP 限制短时间大量评论的 IP 限流
审核机制新用户首次评论强制审核
验证码可选集成图形/行为验证码

8.2 通知机制

新评论到达时通过 SSE 推送通知管理员:

// Core Service:评论创建后发布事件
func (s *CommentService) Create(ctx context.Context, req *commentV1.CreateCommentRequest) (*commentV1.Comment, error) {
    result, err := s.commentRepo.Create(ctx, req)
    if err != nil {
        return nil, err
    }
    // 发布评论创建事件
    s.eventbus.Publish(ctx, "comment.created", result)
    return result, nil
}

九、检查清单

检查项说明
Comment Schema 定义字段 + 边缘关系
状态流转PENDING → APPROVED / REJECTED
评论树查询顶级评论 + 递归回复
Admin API列表 + 审核 + 删除
App API浏览(免登录)+ 发表(需登录)
管理后台审核页状态筛选 + 批量审核
前台评论组件发表 + 回复 + 多级展示
防垃圾频率限制 + 敏感词

相关文档

  • CMS 后端架构总览
  • Headless API 对接多端实战
  • 实时消息推送实战
  • 前台应用开发实战
Edit this page
Last Updated:: 6/21/26, 8:19 PM
Contributors: Bobo
Prev
媒体资源管理实战教程
Next
事件总线架构实战教程