#评论系统与交互:道满博客的社区温度落地
完成了道满博客的核心发布、用户认证、数据库关系梳理后,最能拉近距离的社区入口——带软删除/踩权限的递归评论、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 扁平列表转树形结构
数据库返回的所有评论都是扁平列表(每条有自己的 id 和 parent_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. 小结与性能提示
#核心要点回顾
- 数据模型:用
parent_id+remote_side实现自引用一对多 - 后端路由:权限优先、软删除防误删、AJAX 轻量更新
- 前端渲染:工具函数转扁平为树形、Jinja2 宏递归调用
#简单的性能优化
- 评论折叠:对于嵌套超过 3 层的子评论,默认折叠显示「查看 X 条回复」
- 缓存热门文章的评论树:对访问量 top10 的文章,缓存
build_comment_tree()的结果 - 延迟加载回复:根评论显示时只加载直接子评论,点击「查看更多回复」再加载后续层级
🔗 扩展阅读

