评论与审核
学习目标
为公开文章添加登录后评论、长度校验、审核状态和分页,并理解频率限制和数据库锁的适用边界。
为什么让评论先经过审核
公开输入会带来垃圾信息和不合适内容。小型博客可以采用待审核模式,作者提交后收到反馈,管理员审核通过才公开。模板仍把评论当纯文本,不能因为“审核过”就取消输出转义。
一条评论关联文章和用户,文章删除时评论一起删除。每个用户两次评论至少间隔 30 秒;为了避免同一用户并发请求同时通过检查,在事务中先锁住用户行,再检查最近记录。MySQL 支持这一行锁,SQLite 不提供同等语义,因此 SQLite 用于入门开发,真实并发验收必须使用部署数据库。
具体怎么做
- 增加 Comment 模型与迁移、CommentForm、POST 视图和路由。
- 评论仅对已发布文章开放,服务端获取当前用户和文章,不接收提交者指定的 author。
- 详情公开查询加入 approved=True,并使用分页和 select_related 读取作者。
- 后台注册 Comment,只允许审核和删除现有评论,不允许随意代用户新增。
python manage.py makemigrations blog
python manage.py migrate
本章新增或替换的完整文件
以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。
blog/admin.py
from django.contrib import admin
from .models import Category, Post
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "author", "category", "status", "created_at")
list_filter = ("status", "category")
search_fields = ("title", "body", "author__username")
autocomplete_fields = ("category",)
list_select_related = ("author", "category")
readonly_fields = ("author", "created_at", "updated_at")
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset if request.user.is_superuser else queryset.filter(author=request.user)
def save_model(self, request, obj, form, change):
if not change:
obj.author = request.user
super().save_model(request, obj, form, change)
from .models import Comment
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ("id", "post", "author", "approved", "created_at")
list_filter = ("approved",)
search_fields = ("body", "author__username")
list_select_related = ("post", "author")
readonly_fields = ("post", "author", "body", "created_at")
def has_add_permission(self, request):
return False
blog/forms.py
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ("title", "category", "body", "status", "cover")
widgets = {"body": forms.Textarea(attrs={"rows": 12})}
def clean_title(self):
title = self.cleaned_data["title"].strip()
if len(title) < 5:
raise forms.ValidationError("标题至少需要 5 个字符。")
return title
def clean_body(self):
body = self.cleaned_data["body"].strip()
if len(body) < 20:
raise forms.ValidationError("请至少输入 20 个字符,说明你的想法。")
return body
def clean_cover(self):
image = self.cleaned_data.get("cover")
if not image or not hasattr(image, "image"):
return image
if image.size > 2 * 1024 * 1024:
raise forms.ValidationError("图片不能超过 2 MB。")
if image.image.format not in {"JPEG", "PNG", "WEBP"}:
raise forms.ValidationError("只支持 JPEG、PNG 和 WebP 图片。")
if image.image.width * image.image.height > 16_000_000:
raise forms.ValidationError("图片像素过大,请缩小后再上传。")
suffixes = {"JPEG": ".jpg", "PNG": ".png", "WEBP": ".webp"}
image.name = "cover" + suffixes[image.image.format]
return image
from .models import Comment
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ("body",)
widgets = {"body": forms.Textarea(attrs={"rows": 3, "maxlength": 1000})}
def clean_body(self):
body = self.cleaned_data["body"].strip()
if len(body) < 2:
raise forms.ValidationError("评论至少需要 2 个字符。")
return body
blog/migrations/0003_comment.py
# Generated by Django 6.1.2 on 2026-11-01 11:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_post_cover'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(max_length=1000, verbose_name='评论')),
('approved', models.BooleanField(default=False, verbose_name='已审核')),
('created_at', models.DateTimeField(auto_now_add=True)),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post')),
],
options={
'ordering': ['-created_at', '-pk'],
'indexes': [models.Index(fields=['author', '-created_at'], name='comment_author_time_idx')],
},
),
]
blog/models.py
from django.conf import settings
from django.db import models
from django.urls import reverse
from .uploads import cover_path
class Category(models.Model):
name = models.CharField("名称", max_length=40, unique=True)
slug = models.SlugField("地址标识", unique=True)
class Meta:
ordering = ["name", "pk"]
def __str__(self):
return self.name
class Post(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "草稿"
PUBLISHED = "published", "已发布"
title = models.CharField("标题", max_length=120)
body = models.TextField("正文")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="posts")
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="posts")
status = models.CharField("状态", max_length=12, choices=Status, default=Status.DRAFT)
cover = models.ImageField("封面", upload_to=cover_path, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("修改时间", auto_now=True)
class Meta:
ordering = ["-created_at", "-pk"]
indexes = [models.Index(fields=["status", "-created_at"], name="post_status_created_idx")]
constraints = [models.CheckConstraint(condition=models.Q(status__in=["draft", "published"]), name="post_valid_status")]
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("blog:detail", args=[self.pk])
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
body = models.TextField("评论", max_length=1000)
approved = models.BooleanField("已审核", default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at", "-pk"]
indexes = [models.Index(fields=["author", "-created_at"], name="comment_author_time_idx")]
blog/urls.py
from django.urls import path
from . import views
app_name = "blog"
urlpatterns = [
path("", views.home, name="home"),
path("mine/", views.mine, name="mine"),
path("posts/new/", views.create, name="create"),
path("posts/<int:pk>/comments/", views.comment, name="comment"),
path("posts/<int:pk>/edit/", views.edit, name="edit"),
path("posts/<int:pk>/delete/", views.delete, name="delete"),
path("posts/<int:pk>/", views.detail, name="detail"),
path("about/", views.about, name="about"),
path("old-home/", views.old_home, name="old_home"),
]
blog/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.views.decorators.http import require_GET, require_http_methods
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from .forms import PostForm, CommentForm
from .models import Comment
from django.db import transaction
from django.contrib.auth import get_user_model
from django.utils import timezone
from datetime import timedelta
from django.views.decorators.http import require_POST
from django.core.paginator import Paginator
from django.db.models import Q
from .models import Category, Post
@require_GET
def home(request):
posts = Post.objects.filter(status=Post.Status.PUBLISHED).select_related("author", "category")
q = request.GET.get("q", "").strip()[:100]
category = request.GET.get("category", "").strip()[:50]
if q:
posts = posts.filter(Q(title__icontains=q) | Q(body__icontains=q))
if category:
posts = posts.filter(category__slug=category)
page_obj = Paginator(posts, 6).get_page(request.GET.get("page"))
return render(request, "blog/home.html", {"page_obj": page_obj, "q": q,
"selected_category": category, "categories": Category.objects.all()})
@require_GET
def detail(request, pk):
visible = Q(status=Post.Status.PUBLISHED)
if request.user.is_authenticated:
visible |= Q(author=request.user)
post = get_object_or_404(Post.objects.select_related("author", "category").filter(visible), pk=pk)
comments = post.comments.filter(approved=True).select_related("author")
page_obj = Paginator(comments, 10).get_page(request.GET.get("page"))
return render(request, "blog/detail.html", {"post": post, "page_obj": page_obj, "comment_form": CommentForm()})
@require_GET
def about(request):
return render(request, "blog/about.html")
@require_GET
def old_home(request):
return redirect("blog:home")
@login_required
@require_GET
def mine(request):
page_obj = Paginator(Post.objects.filter(author=request.user).select_related("category"), 10).get_page(request.GET.get("page"))
return render(request, "blog/mine.html", {"page_obj": page_obj})
@login_required
@require_http_methods(["GET", "POST"])
def create(request):
form = PostForm(request.POST if request.method == "POST" else None, request.FILES or None)
if request.method == "POST" and form.is_valid():
post = form.save(commit=False)
post.author = request.user
post.save()
messages.success(request, "文章已保存。")
return redirect(post)
return render(request, "blog/form.html", {"form": form, "heading": "写文章"})
@login_required
@require_http_methods(["GET", "POST"])
def edit(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
form = PostForm(request.POST if request.method == "POST" else None, request.FILES or None, instance=post)
if request.method == "POST" and form.is_valid():
form.save()
messages.success(request, "修改已保存。")
return redirect(post)
return render(request, "blog/form.html", {"form": form, "heading": "编辑文章"})
@login_required
@require_http_methods(["GET", "POST"])
def delete(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
if request.method == "POST":
post.delete()
messages.success(request, "文章已删除。")
return redirect("blog:mine")
return render(request, "blog/confirm_delete.html", {"post": post})
@login_required
@require_POST
def comment(request, pk):
post = get_object_or_404(Post, pk=pk, status=Post.Status.PUBLISHED)
form = CommentForm(request.POST)
if not form.is_valid():
messages.error(request, "评论内容需要 2–1000 个字符,请检查后重新提交。")
return redirect(post)
with transaction.atomic():
# MySQL 上同一用户的并发评论共用这把行锁;SQLite 没有等效的行锁。
get_user_model().objects.select_for_update().get(pk=request.user.pk)
recent = Comment.objects.filter(author=request.user, created_at__gte=timezone.now() - timedelta(seconds=30))
if recent.exists():
messages.error(request, "评论间隔至少 30 秒,请稍后再试。")
else:
Comment.objects.create(post=post, author=request.user, body=form.cleaned_data["body"])
messages.success(request, "评论已提交,审核后展示。")
return redirect(post)
templates/blog/detail.html
{% extends "base.html" %}
{% block title %}{{ post.title }} · 知行博客{% endblock %}
{% block content %}<article>
<h1>{{ post.title }}</h1><p class="muted">{{ post.author.username }} · {{ post.category.name }} · {{ post.created_at|date:"Y-m-d H:i" }}</p>
{% if post.status == 'draft' %}<p>这是一篇草稿,只有作者可以查看。</p>{% endif %}
{% if user.pk == post.author_id %}<p><a href="{% url 'blog:edit' post.pk %}">编辑</a> · <a href="{% url 'blog:delete' post.pk %}">删除</a></p>{% endif %}
{% if post.cover %}<img src="{{ post.cover.url }}" alt="{{ post.title }}的封面" loading="lazy">{% endif %}
<div>{{ post.body|linebreaks }}</div>
</article><section aria-labelledby="comments-heading"><h2 id="comments-heading">评论</h2>
{% for item in page_obj %}<article><p class="muted">{{ item.author.username }} · {{ item.created_at|date:"Y-m-d H:i" }}</p>{{ item.body|linebreaks }}</article>{% empty %}<p>还没有公开评论。</p>{% endfor %}
{% include "blog/pagination.html" %}
{% if post.status == 'published' %}{% if user.is_authenticated %}<form method="post" action="{% url 'blog:comment' post.pk %}" class="panel">{% csrf_token %}{{ comment_form.as_p }}<button type="submit">提交评论</button><p>审核后展示;请勿提交私人信息。</p></form>{% else %}<a href="{% url 'login' %}?next={{ request.path|urlencode }}">登录后评论</a>{% endif %}{% endif %}
</section>{% endblock %}
如何确认成功
登录普通账号,提交 2–1000 字符的评论,应提示“审核后展示”,公开页面暂时看不到。管理员勾选审核后出现评论。30 秒内再次提交不会新增记录;过期后可以重新提交。
尝试未登录提交、GET 评论地址、评论草稿文章和只包含空白的内容,分别验证身份、方法、可见性及校验边界。方法检查可能在 CSRF 之前或之后受到中间件影响,第 21 章会用真实 CSRF 测试确认。
练习:准备超过 10 条已审核评论,观察下一页仍留在当前文章。解释为什么“管理员后台有审核权限”不等于“任何 staff 都可随意管理评论”,需要明确分配模型权限。
常见问题
验证失败为什么没有保留评论输入? 这个最小示例采用消息加重定向,适合短评论;改进练习是把失败表单连同详情数据重新渲染,保留输入,并为重复提交增加幂等键。验证码能替代频率限制吗? 不能,两者处理的问题不同。本站已有点选验证码、重复提交和更完善的评论服务,可在第 36 章按路径深入阅读。
官方参考
登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。
登录并保存学习进度 →联系站长
微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。
章节讨论 0