Django 从入门到精通:知行博客实战(6.1) · 第 19 / 36 节

评论与审核

吴秀峰 3 次阅读 0 条评论 ↓

学习目标

为公开文章添加登录后评论、长度校验、审核状态和分页,并理解频率限制和数据库锁的适用边界。

为什么让评论先经过审核

公开输入会带来垃圾信息和不合适内容。小型博客可以采用待审核模式,作者提交后收到反馈,管理员审核通过才公开。模板仍把评论当纯文本,不能因为“审核过”就取消输出转义。

一条评论关联文章和用户,文章删除时评论一起删除。每个用户两次评论至少间隔 30 秒;为了避免同一用户并发请求同时通过检查,在事务中先锁住用户行,再检查最近记录。MySQL 支持这一行锁,SQLite 不提供同等语义,因此 SQLite 用于入门开发,真实并发验收必须使用部署数据库。

具体怎么做

  1. 增加 Comment 模型与迁移、CommentForm、POST 视图和路由。
  2. 评论仅对已发布文章开放,服务端获取当前用户和文章,不接收提交者指定的 author。
  3. 详情公开查询加入 approved=True,并使用分页和 select_related 读取作者。
  4. 后台注册 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