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

完成了道满博客的核心发布、用户认证、数据库关系梳理后,最能拉近距离的社区入口——带软删除/踩权限的递归评论、AJAX 无刷新点赞踩终于要上线啦!

本文是「第四阶段 · 实战演练」的第三篇核心交付,我们将从数据模型自引用设计带权限的后端路由树形结构递归工具+模板三个维度,快速搭建一套轻量但完整的博客评论系统。

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


1. 数据模型:用自引用一对多搞定递归层级

要实现无限嵌套(或者带折叠的有限层级)的评论,核心是自引用一对多的数据库关系——每条评论可以有一个「父评论」,同时可以有多个「子回复」。

这里补充了原代码缺少的 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 是被引用的一方(即父级)」
    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}>"

2. 后端路由:权限优先、异步轻量

路由分为三类:发布根/子评论软删除评论(含权限校验)AJAX 无刷新点赞/踩

2.1 发布与删除

根评论和子评论的发布逻辑几乎一致,只需要判断 parent_id 是否为空即可。删除时做了严格的权限控制:只能删自己的,或者管理员全删;且用软删除替代物理删除:

# 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))

2.2 AJAX 点赞/踩

点赞/踩不需要刷新页面,用 jsonify 返回当前的点赞踩数即可:

# 点赞
@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})

3. 前端渲染:递归工具+Jinja2 宏,轻松搞定树形评论

3.1 扁平列表转树形结构

数据库返回的所有评论都是扁平列表(每条有自己的 idparent_id),我们需要先写一个工具函数把它转成树状字典

# app/utils/comments.py
def build_comment_tree(comments):
    """
    将数据库返回的扁平 Comment 列表转为树状字典
    输入:[Comment1(根), Comment2(根), Comment3(Comment1的子), ...]
    输出:[{"comment": Comment1, "replies": [...]}, {"comment": Comment2, "replies": [...]}, ...]
    """
    # 第一步:用字典存所有评论的临时节点(键是评论ID)
    comment_nodes = {}
    root_comments = []
    for comment in comments:
        comment_nodes[comment.id] = {"comment": comment, "replies": []}

    # 第二步:遍历所有节点,找到父节点并挂到 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 递归宏渲染

Jinja2 支持宏的递归调用,正好可以完美适配树形结构:

<!-- 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 %}

4. 小结与性能提示

核心要点回顾

  1. 数据模型:用 parent_id + remote_side 实现自引用一对多
  2. 后端路由:权限优先、软删除防误删、AJAX 轻量更新
  3. 前端渲染:工具函数转扁平为树形、Jinja2 宏递归调用

简单的性能优化

  • 评论折叠:对于嵌套超过 3 层的子评论,默认折叠显示「查看 X 条回复」
  • 缓存热门文章的评论树:对访问量 top10 的文章,缓存 build_comment_tree() 的结果
  • 延迟加载回复:根评论显示时只加载直接子评论,点击「查看更多回复」再加载后续层级

🔗 扩展阅读