博客能被人读,也需要能被人回应。我先加上匿名评论、顶和踩、热门文章。互动功能第一次把不可信输入带进数据库,验证和转义因此比表单本身更重要。
评论请求先经过表单验证,再绑定到已发布文章;投票请求先找到文章和浏览器会话,再检查是否已经投过。两者都只在验证通过后写库。
评论先建模型再建表单
Comment 和 Post 是多对一关系。一条评论必须依附于一篇文章,文章删除时评论没有单独存在的意义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from django.db import models
class Comment ( models . Model ):
post = models . ForeignKey (
Post ,
verbose_name = "文章" ,
on_delete = models . CASCADE ,
related_name = "comments" ,
)
nickname = models . CharField ( "昵称" , max_length = 30 )
email = models . EmailField ( "邮箱" )
body = models . TextField ( "内容" , max_length = 2000 )
created_at = models . DateTimeField ( "创建时间" , auto_now_add = True )
class Meta :
verbose_name = "评论"
verbose_name_plural = verbose_name
ordering = [ "created_at" ]
def __str__ ( self ):
return f " { self . nickname } 评论《 { self . post . title } 》"
邮箱暂不要求验证后展示。它可以留给未来回复通知使用,先由 EmailField 做格式校验。昵称和内容设上限,能挡掉最明显的滥用。
表单交给 Django 的 ModelForm。
1
2
3
4
5
6
7
8
9
10
11
12
from django import forms
from .models import Comment
class CommentForm ( forms . ModelForm ):
class Meta :
model = Comment
fields = [ "nickname" , "email" , "body" ]
widgets = {
"nickname" : forms . TextInput ( attrs = { "placeholder" : "昵称" , "required" : True }),
"email" : forms . EmailInput ( attrs = { "placeholder" : "邮箱" , "required" : True }),
"body" : forms . Textarea ( attrs = { "placeholder" : "写下你的看法" , "rows" : 4 , "required" : True }),
}
fields 显式列出三个字段。文章主键由 URL 决定,创建时间由数据库生成,这两个都不应该让用户提交。
视图里先找已发布文章,再验证表单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from django.contrib import messages
from django.shortcuts import get_object_or_404 , redirect
from .forms import CommentForm
from .models import Post
def add_comment ( request , pk ):
post = get_object_or_404 ( Post . published , pk = pk )
form = CommentForm ( request . POST )
if form . is_valid ():
comment = form . save ( commit = False )
comment . post = post
comment . save ()
messages . success ( request , "评论已提交。" )
else :
messages . error ( request , "请检查昵称、邮箱和评论内容。" )
return redirect ( post )
save(commit=False) 先生成对象但不落库,等视图把文章关联上再保存。这样用户只能影响表单列出的字段,不能通过伪造数据把评论挂到别的对象上。
失败时也不需要重新渲染整个详情页。重定向回文章,让 Django messages 显示错误。浏览器刷新后表单还在,用户可以改完再提交。
模板默认转义就是防线
详情页展示评论。
1
2
3
4
5
6
{% for comment in post.comments.all %}
< article class = "comment" >
< p class = "meta" > {{ comment.nickname }} · {{ comment.created_at|date:"Y-m-d H:i" }}</ p >
< p > {{ comment.body|linebreaks }}</ p >
</ article >
{% endfor %}
{{ comment.body }} 默认转义。有人提交 <script>alert(1)</script>,页面显示这段文字,浏览器不会执行它。测试里同时断言转义后的文本出现,原始脚本不出现。
1
2
self . assertContains ( response , "<script>" )
self . assertNotContains ( response , "<script>alert(1)</script>" )
这里没有用 Markdown 渲染评论。读者输入不是受控内容,让匿名用户写 HTML 或嵌套 Markdown,会把安全问题扩大。评论先保留纯文本和换行。
顶和踩要用数据库约束
文章模型加两个计数列。
1
2
3
class Post ( models . Model ):
likes = models . PositiveIntegerField ( "顶" , default = 0 )
dislikes = models . PositiveIntegerField ( "踩" , default = 0 )
投票单独建表。只给文章加计数列,刷新页面就多一票,无法限制重复。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Vote ( models . Model ):
post = models . ForeignKey (
Post ,
verbose_name = "文章" ,
on_delete = models . CASCADE ,
related_name = "votes" ,
)
session_key = models . CharField ( "浏览器标识" , max_length = 64 , db_index = True )
value = models . SmallIntegerField ( "票值" , choices = [( 1 , "顶" ), ( - 1 , "踩" )])
created_at = models . DateTimeField ( "创建时间" , auto_now_add = True )
class Meta :
constraints = [
models . UniqueConstraint ( fields = [ "post" , "session_key" ], name = "unique_post_vote" )
]
UniqueConstraint 比应用层判断可靠。两个请求几乎同时到达时,数据库只允许一条记录插入成功。Session Key 不是绝对身份,清 Cookie 后可以再投,这个阶段先接受这个限制。
投票必须是 POST。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from django.db.models import F
from django.views.decorators.http import require_POST
from .models import Post , Vote
@require_POST
def vote ( request , pk , value ):
post = get_object_or_404 ( Post . published , pk = pk )
vote_value = 1 if value == 1 else - 1
if not request . session . session_key :
request . session . create ()
_ , created = Vote . objects . get_or_create (
post = post ,
session_key = request . session . session_key ,
defaults = { "value" : vote_value },
)
if not created :
messages . info ( request , "你已经对这篇文章投过票了。" )
return redirect ( post )
if vote_value == 1 :
Post . objects . filter ( pk = pk ) . update ( likes = F ( "likes" ) + 1 )
else :
Post . objects . filter ( pk = pk ) . update ( dislikes = F ( "dislikes" ) + 1 )
messages . success ( request , "感谢你的反馈。" )
return redirect ( post )
require_POST 阻止 GET 修改数据。get_or_create 和数据库唯一约束一起处理并发。计数更新用 F 表达式,和上一篇浏览量的处理一致。
URL 里的 value 用整数转换器,模板把踩传成 0,视图再映射为 -1。Django 的 <int> 转换器不接受负号,这样路由仍然保持严格。
热门文章先只看浏览量
侧边栏的热门列表已经存在,现在把口径说清楚。第一阶段先用浏览量排序。
1
popular_posts = Post . published . order_by ( "-views" )[: 5 ]
浏览量容易被详情页访问抬高,不代表质量。但它的定义清楚,数据实时,实现便宜。等评论、顶踩积累起来,可以改成加权公式,或者改成后台可配置。现在写一个复杂评分,反而很难解释一篇文章为什么热门。
最新评论也进入侧边栏。
1
recent_comments = Comment . objects . select_related ( "post" )[: 5 ]
select_related 把评论和文章一次取回。侧边栏只需要文章标题和链接,不需要评论正文。
详情页下方显示评论数量、列表、表单、顶和踩。
1
2
3
4
5
6
7
8
9
10
11
< h2 > 评论({{ post.comments.count }})</ h2 >
< div class = "votes" >
< form method = "post" action = "{% url 'blog:vote' post.pk 1 %}" >
{% csrf_token %}
< button type = "submit" > 顶({{ post.likes }})</ button >
</ form >
< form method = "post" action = "{% url 'blog:vote' post.pk 0 %}" >
{% csrf_token %}
< button type = "submit" > 踩({{ post.dislikes }})</ button >
</ form >
</ div >
comments.count 会执行一次计数查询。当前详情页数据量小,可读性优先。以后如果计数成为瓶颈,可以在文章上维护评论数缓存列。
真实浏览器打开详情页,提交一条评论,再点一次顶,页面会回到原位并显示提示。
测试互动行为和安全边界
评论测试覆盖成功、失败和转义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def test_comment_can_be_created_and_is_escaped ( self ):
response = self . client . post ( reverse ( "blog:comment" , args = [ self . post . pk ]), {
"nickname" : "小明" ,
"email" : "ming@example.com" ,
"body" : "<script>alert(1)</script>这条评论很有用。" ,
}, follow = True )
self . assertContains ( response , "评论已提交。" )
self . assertContains ( response , "<script>" )
self . assertNotContains ( response , "<script>alert(1)</script>" )
self . assertEqual ( Comment . objects . count (), 1 )
def test_invalid_comment_is_not_saved ( self ):
response = self . client . post ( reverse ( "blog:comment" , args = [ self . post . pk ]), {
"nickname" : "" ,
"email" : "bad-email" ,
"body" : "" ,
}, follow = True )
self . assertContains ( response , "请检查昵称、邮箱和评论内容。" )
self . assertEqual ( Comment . objects . count (), 0 )
投票测试用同一个测试客户端发两次 POST。
1
2
3
4
5
6
7
8
def test_vote_once_per_session ( self ):
url = reverse ( "blog:vote" , args = [ self . post . pk , 1 ])
self . client . post ( url )
self . client . post ( url )
self . post . refresh_from_db ()
self . assertEqual ( self . post . likes , 1 )
self . assertEqual ( Vote . objects . count (), 1 )
热门排序测试创建两篇文章,给其中一篇更高的 views,再检查列表侧边栏里的顺序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_popular_posts_are_ordered_by_views ( self ):
second = Post . objects . create (
title = "更热门的文章" ,
slug = "more-popular" ,
category = self . category ,
author = self . user ,
status = Post . Status . PUBLISHED ,
published_at = "2026-09-25T09:00:00+08:00" ,
views = 5 ,
)
self . post . views = 1
self . post . save ()
response = self . client . get ( reverse ( "blog:list" ))
content = response . content . decode ()
self . assertLess ( content . index ( second . title ), content . index ( self . post . title ))
运行迁移和测试。
1
2
3
uv run manage.py makemigrations blog
uv run manage.py migrate
uv run manage.py test
迁移会创建评论、投票表,并给文章加两个计数列。
1
2
3
4
5
6
Migrations for 'blog':
blog\migrations\0003_post_dislikes_post_likes_comment_vote.py
+ Add field dislikes to post
+ Add field likes to post
+ Create model Comment
+ Create model Vote
完整测试结果如下。
1
2
3
Ran 19 tests in 2.918s
OK
博客不再只是单向输出。评论有了验证和转义,投票有了数据库约束,热门文章有了明确口径。这些功能都不复杂,但每一条都留下了测试。
源码
GitHub 地址:https://github.com/yidao620c/simpleblog