评论系统与交互:让道满博客的社区温度落地

完成了文章发布、用户认证、数据库关系梳理之后,博客还差最后一块拼图——评论系统。这一模块是读者和作者之间最直接的互动窗口,也是社区氛围的起点。

本文是「第四阶段 · 实战演练」的核心交付之一,我们将从 数据模型的自引用设计带权限的后端路由递归工具与前端渲染 三个维度,搭建一套轻量但完整的评论功能,包含:

  • 支持无限嵌套的递归评论
  • 软删除与权限校验(只能删自己的评论,管理员可全删)
  • AJAX 无刷新点赞 / 踩
  • 树形结构渲染,视觉层级清晰

📂 所属阶段:第四阶段 — 实战演练(道满博客)
🔗 前置/关联阅读:数据库关系自引用一对多 · Flask-Login 认证与权限校验


1. 数据模型:自引用一对多,一层层搭起评论树

要想实现“评论下面还能继续回复”这种多层级对话,数据库里需要一种特殊的关系——自引用一对多。简单来说,就是给每条评论增加一个 parent_id 字段,指向它的“父评论”。如果一条评论没有父评论,那它就是根评论(一级评论)。

下面是我们定义好的 Comment 模型,除了常规的内容、所属文章、作者等字段之外,还特意加入了:

  • dislikes:踩数,与点赞形成对称交互
  • is_deleted:软删除标记,避免误删后无法恢复
# app/models/comment.py
from datetime import datetime
from app.extensions import db

class Comment(db.Model):
    __tablename__ = "comments"

    id = db.Column(db.Integer, primary_key=True)
    content = db.Column(db.Text, nullable=False, comment="评论内容")
    likes = db.Column(db.Integer, default=0, comment="点赞数")
    dislikes = db.Column(db.Integer, default=0, comment="踩数")
    is_deleted = db.Column(db.Boolean, default=False, comment="软删除标记")
    created_at = db.Column(db.DateTime, default=datetime.utcnow, comment="发布时间")

    # 外键关联
    post_id = db.Column(
        db.Integer,
        db.ForeignKey("posts.id", ondelete="CASCADE"),
        nullable=False,
        comment="关联文章ID"
    )
    author_id = db.Column(
        db.Integer,
        db.ForeignKey("users.id", ondelete="CASCADE"),
        nullable=False,
        comment="发布者ID"
    )
    parent_id = db.Column(
        db.Integer,
        db.ForeignKey("comments.id", ondelete="CASCADE"),
        nullable=True,
        comment="父评论ID(根评论时为空)"
    )

    # SQLAlchemy 关系映射
    post = db.relationship("Post", back_populates="comments")
    author = db.relationship("User", backref="comments")

    # 自引用关系:remote_side=[id] 指明“当前模型的 id 是被引用的一方(即父级)”
    parent = db.relationship(
        "Comment",
        remote_side=[id],
        backref=db.backref("replies", lazy="dynamic")
    )

    def __repr__(self):
        return f"<Comment {self.id} by User {self.author_id} on Post {self.post_id}>"

关键点: remote_side=[id] 告诉 SQLAlchemy,在 parent_idid 的这条线中,id 列才是“远程被引用”的那一端。这样就能通过 comment.replies 直接拿到当前评论的所有子回复。


2. 后端路由:权限优先,操作轻量

评论相关的后端接口主要分为三类:

  • 发布评论:既可以是根评论,也可以是子回复
  • 删除评论:软删除 + 严格权限控制
  • 点赞 / 踩:AJAX 无刷新更新数据

2.1 发布与删除

发布时只需要判断是否存在 parent_id,就能自然区分根评论和回复。删除时我们做了两层保护:一是必须本人或管理员才能删除,二是删除操作只是将 is_deleted 设为 True,而不是物理删除数据。

# app/comments/routes.py
from flask import Blueprint, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from app.extensions import db
from app.models import Comment, Post

comments_bp = Blueprint("comments", __name__, url_prefix="/comments")

# 发布根评论 / 子回复
@comments_bp.route("/add", methods=["POST"])
@login_required
def add_comment():
    post_id = request.form.get("post_id", type=int)
    content = request.form.get("content", "").strip()
    parent_id = request.form.get("parent_id", type=int) or None

    # 基础校验
    if not post_id:
        flash("请选择关联文章", "danger")
        return redirect(request.referrer or url_for("home.index"))
    if not content:
        flash("评论内容不能为空哦", "warning")
        return redirect(url_for("articles.detail", post_id=post_id))
    if len(content) > 500:
        flash("评论内容不能超过500字", "warning")
        return redirect(url_for("articles.detail", post_id=post_id))

    # 验证文章存在
    post = Post.query.get_or_404(post_id)

    # 新增评论
    comment = Comment(
        content=content,
        post_id=post_id,
        author_id=current_user.id,
        parent_id=parent_id,
    )
    db.session.add(comment)
    db.session.commit()
    flash("评论发布成功!", "success")
    return redirect(url_for("articles.detail", post_id=post_id))

# 软删除评论
@comments_bp.route("/<int:comment_id>/delete", methods=["POST"])
@login_required
def delete_comment(comment_id):
    comment = Comment.query.get_or_404(comment_id)

    # 权限校验:只有作者本人或管理员可以删除
    if comment.author_id != current_user.id and not current_user.is_admin:
        flash("无权删除此评论", "danger")
        return redirect(url_for("articles.detail", post_id=comment.post_id))

    comment.is_deleted = True
    db.session.commit()
    flash("评论已隐藏(可联系管理员恢复)", "info")
    return redirect(url_for("articles.detail", post_id=comment.post_id))

💡 说明:is_deleted=True 时,渲染模板会显示「该评论已被隐藏」,而不会暴露原始内容,这样既保留了数据又保护了社区氛围。

2.2 点赞与踩 —— AJAX 无刷新更新

点赞和踩希望做到“点一下数字就变”,不需要整个页面刷新。我们只需提供两个接收 POST 请求的路由,返回 JSON 格式的最新数据,然后由前端 JavaScript 处理按钮状态。

# 点赞
@comments_bp.route("/<int:comment_id>/like", methods=["POST"])
@login_required
def like_comment(comment_id):
    comment = Comment.query.get_or_404(comment_id)
    if not comment.is_deleted:
        comment.likes += 1
        db.session.commit()
    return jsonify({
        "status": "ok",
        "likes": comment.likes,
        "dislikes": comment.dislikes
    })

# 踩
@comments_bp.route("/<int:comment_id>/dislike", methods=["POST"])
@login_required
def dislike_comment(comment_id):
    comment = Comment.query.get_or_404(comment_id)
    if not comment.is_deleted:
        comment.dislikes += 1
        db.session.commit()
    return jsonify({
        "status": "ok",
        "likes": comment.likes,
        "dislikes": comment.dislikes
    })

这时需要在文章详情页面引入一小段 JavaScript(例如使用 fetch),将点赞按钮的点击事件绑定到这些路由上,并实时更新页面上的数字。具体的前端代码这里先略去,但是思路非常简单:点击 → 请求 → 更新 DOM。


3. 前端渲染:扁平列表变树,Jinja2 宏递归出层级

数据库查询出来的评论是一个扁平的列表,每条评论只知道自己的 parent_id。这样的数据无法直接在页面上按层级展开。我们需要:

  1. 先用工具函数把扁平列表转化为树形结构
  2. 再用 Jinja2 宏进行递归渲染

3.1 构建评论树

下面这个 build_comment_tree 函数会遍历所有评论,为每条评论创建一个包含 commentreplies 的节点。然后根据 parent_id 把节点挂到对应的父节点下,最后返回所有根评论组成的列表。

# app/utils/comments.py
def build_comment_tree(comments):
    """
    将数据库返回的扁平 Comment 列表转为树状结构
    输入: [Comment1(根), Comment2(根), Comment3(Comment1的子), ...]
    输出: [
        {"comment": Comment1, "replies": [{"comment": Comment3, "replies": []}]},
        {"comment": Comment2, "replies": []},
        ...
    ]
    """
    comment_nodes = {}
    root_comments = []

    for comment in comments:
        comment_nodes[comment.id] = {"comment": comment, "replies": []}

    for node in comment_nodes.values():
        parent_id = node["comment"].parent_id
        if parent_id and parent_id in comment_nodes:
            comment_nodes[parent_id]["replies"].append(node)
        elif not parent_id:
            root_comments.append(node)

    return root_comments

3.2 递归宏渲染

Jinja2 宏支持在内部调用自身,正好可以完美地渲染嵌套结构。我们创建一个 render_comments 宏,它接收一个树形节点列表,先渲染当前层的所有评论,再在每一层内部继续调用自己渲染子回复。

为了视觉上看起来清晰,我们给子回复增加左边距(ml-16),形成阶梯式缩进。

<!-- templates/comments/comment_tree.html -->
{% macro render_comments(comment_tree) %}
<ul class="space-y-4 list-none pl-0">
    {% for item in comment_tree %}
    <li class="comment-item">
        <div class="flex gap-3 p-4 rounded-lg bg-gray-50 border border-gray-100">
            <!-- 头像 -->
            <img 
                src="{{ item.comment.author.get_avatar(48) }}" 
                alt="{{ item.comment.author.username }}" 
                class="w-12 h-12 rounded-full object-cover flex-shrink-0"
            >
            <!-- 评论主体 -->
            <div class="flex-1 min-w-0">
                <!-- 元信息(用户名+时间) -->
                <div class="flex items-center gap-2 mb-1">
                    <strong class="text-gray-800">{{ item.comment.author.username }}</strong>
                    <span class="text-xs text-gray-400">
                        {{ item.comment.created_at.strftime('%Y-%m-%d %H:%M') }}
                    </span>
                </div>
                <!-- 评论内容 -->
                <div class="text-gray-700 leading-relaxed mb-3">
                    {% if item.comment.is_deleted %}
                        <em class="text-gray-400 italic">[该评论已被隐藏]</em>
                    {% else %}
                        {{ item.comment.content | safe }}
                    {% endif %}
                </div>
                <!-- 评论操作区(仅未删除时显示) -->
                {% if not item.comment.is_deleted %}
                <div class="flex items-center gap-4 text-sm text-gray-500">
                    <!-- 点赞按钮(AJAX) -->
                    <button 
                        class="like-btn hover:text-blue-500 transition-colors" 
                        data-id="{{ item.comment.id }}"
                    >
                        👍 {{ item.comment.likes }}
                    </button>
                    <!-- 踩按钮(AJAX) -->
                    <button 
                        class="dislike-btn hover:text-red-500 transition-colors" 
                        data-id="{{ item.comment.id }}"
                    >
                        👎 {{ item.comment.dislikes }}
                    </button>
                    <!-- 回复按钮 -->
                    <button 
                        class="reply-btn hover:text-green-500 transition-colors" 
                        data-id="{{ item.comment.id }}"
                        data-username="{{ item.comment.author.username }}"
                    >
                        💬 回复
                    </button>
                    <!-- 删除按钮(仅自己或管理员可见) -->
                    {% if current_user.is_authenticated and (current_user.id == item.comment.author_id or current_user.is_admin) %}
                    <form 
                        method="POST" 
                        action="{{ url_for('comments.delete_comment', comment_id=item.comment.id) }}" 
                        style="display:inline;"
                        onsubmit="return confirm('确定要隐藏这条评论吗?');"
                    >
                        <button type="submit" class="hover:text-red-600 transition-colors">
                            🗑️ 删除
                        </button>
                    </form>
                    {% endif %}
                </div>
                {% endif %}
            </div>
        </div>

        <!-- 递归渲染子评论(增加左边距形成层级感) -->
        {% if item.replies %}
        <div class="ml-16 mt-3">
            {{ render_comments(item.replies) }}
        </div>
        {% endif %}
    </li>
    {% endfor %}
</ul>
{% endmacro %}

在文章详情页使用这个宏时,只需要先把评论列表传入 build_comment_tree,再调用宏即可:

{% from "comments/comment_tree.html" import render_comments %}
...
{{ render_comments(build_comment_tree(comments)) }}

这样一来,嵌套再深的回复都会自动缩进展示,看起来非常清晰。


4. 小结与优化建议

核心要点回顾

  1. 数据模型:使用 parent_id + remote_side 构建自引用一对多,轻松实现无限层级评论。
  2. 后端路由:权限控制到位(删除、点赞均需登录),软删除保护数据,AJAX 返回 JSON 实现无刷新交互。
  3. 前端渲染:用工具函数将扁平列表转为树形结构,再借 Jinja2 宏递归生成层级分明的 HTML。

几个可选的性能优化方向

  • 评论折叠:当某条评论的子回复超过一定层级(比如 3 层)时,默认折叠隐藏,只显示“展开 X 条回复”按钮。
  • 缓存热门文章的评论树:对于访问量 Top 10 的文章,可以缓存 build_comment_tree() 的结果,并设置合理的过期时间。
  • 延迟加载回复:只先加载根评论以及它们的直接子回复,点击“查看更多回复”后再异步加载更深层级的评论,减轻初始加载压力。

🔗 扩展阅读