#Comment system and interaction: Let the community warmth of Daoman blog be implemented
After completing article publishing, user authentication, and database relationship sorting, the blog still needs the last piece of the puzzle - Comment System. This module is the most direct interaction window between readers and authors, and is also the starting point of the community atmosphere.
This article is one of the core deliveries of "Phase 4·Practical Exercise". We will build a lightweight but complete comment function from three dimensions: Self-reference design of data model, Backend routing with permissions, Recursive tools and front-end rendering, including:
- Supports infinitely nested recursive comments
- Soft deletion and permission verification (you can only delete your own comments, administrators can delete them all)
- AJAX no refresh like/dislike
- Tree structure rendering, clear visual hierarchy
📂 Stage: Stage 4 - Practical Exercise (Daoman Blog)
🔗 Pre-/related reading: 数据库关系自引用一对多 · Flask-Login 认证与权限校验
In order to achieve a multi-level dialogue such as "you can continue to reply below comments", a special relationship is needed in the database - self-referencing one-to-many. To put it simply, add one to each commentparent_idField that points to its "parent comment". If a comment has no parent comment, it is a root comment (a first-level comment).
Below is what we have definedCommentIn addition to the regular content, article, author and other fields, the model also specially adds:
dislikes: Like count, forming a symmetrical interaction with likes
is_deleted: Soft deletion mark to avoid being unable to recover after accidental deletion.
# 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}>"
Key Points:remote_side=[id]Tell SQLAlchemy that inparent_id → idIn this line ofidThe column is the "remotely referenced" end. This way you can passcomment.repliesGet all sub-replies of the current comment directly.
2. Backend routing: permission priority, light operation
Comment-related backend interfaces are mainly divided into three categories:
- Post Comment: It can be either a root comment or a sub-reply
- Delete Comment: soft deletion + strict permission control
- Like/Dislike: AJAX updates data without refreshing
2.1 Publishing and deletion
When publishing, you only need to determine whether it existsparent_id, you can naturally distinguish root comments and replies. We have implemented two layers of protection when deleting: first, the deletion must be done by the person or administrator; second, the deletion operation only involvesis_deletedset toTrue, rather than physically deleting the data.
# 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))
💡 Description: Whenis_deleted=True, the rendering template will display "This comment has been hidden" without exposing the original content, thus preserving the data and protecting the community atmosphere.
2.2 Like and Dislike - AJAX update without refresh
Likes and dislikes hope that the number will change with just one click without the need to refresh the entire page. We simply provide two routes that receive POST requests, return the latest data in JSON format, and then let the front-end JavaScript handle the button state.
# 点赞
@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
})
At this time, you need to introduce a small piece of JavaScript to the article details page (for example, usefetch), bind the click event of the like button to these routes, and update the numbers on the page in real time. The specific front-end code is omitted here, but the idea is very simple: click → Request → Update DOM.
3. Front-end rendering: flat list becomes tree, Jinja2 macro recursively exits the hierarchy
The comments queried from the database are a flat list, and each comment only knows its ownparent_id. Such data cannot be directly expanded hierarchically on the page. we need to:
- First use the tool function to convert the flat list into a tree structure
- Then use Jinja2 macro for recursive rendering
This one belowbuild_comment_treeThe function will iterate through all comments and create a file containingcommentandrepliesnode. Then according toparent_idHang the node under the corresponding parent node, and finally return a list of all root comments.
# 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 Recursive macro rendering
Jinja2 macros support calling themselves internally, which is perfect for rendering nested structures. we create arender_commentsMacro, which receives a tree node list, first renders all comments in the current layer, and then continues to call itself inside each layer to render sub-responses.
For visual clarity, we add a left margin to the sub-reply (ml-16), forming a stepped indentation.
<!-- 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 %}
When using this macro on the article details page, you only need to pass in the comments list firstbuild_comment_tree, and then call the macro:
{% from "comments/comment_tree.html" import render_comments %}
...
{{ render_comments(build_comment_tree(comments)) }}
In this way, replies no matter how deeply nested they are will be automatically indented and displayed, making them look very clear.
4. Summary and optimization suggestions
Review of core points
- Data Model: Use
parent_id + remote_sideBuild self-referencing one-to-many to easily achieve unlimited levels of comments.
- Back-end routing: Permission control is in place (you need to log in to delete and like), soft deletion protects data, and AJAX returns JSON to achieve no-refresh interaction.
- Front-end rendering: Use tool functions to convert the flat list into a tree structure, and then use Jinja2 macros to recursively generate hierarchical HTML.
- Comment Collapse: When the sub-replies of a comment exceed a certain level (such as 3 levels), the fold is hidden by default and only the "Expand X Replies" button is displayed.
- Cache the comment tree of popular articles: Articles with the top 10 most visited articles can be cached
build_comment_tree()results and set a reasonable expiration time.
- Lazy loading of replies: Only load root comments and their direct sub-replies first, and then asynchronously load deeper comments after clicking "View more replies" to reduce the initial loading pressure.
🔗 Extended reading