
如果你已经对着Django官方文档啃过一遍但合上教程还是不知道从哪里下手那这篇博文就是冲着你来的。我最近用Django重新搭了一个博客系统从空目录到能写文章、发评论、跑通部署全程不到三小时。这篇就来把这个全栈开发的过程完整拆一遍包含安装、建项目、数据模型、查询与删除对象、后台定制到上线的每个环节。博客系统是Django项目实战里最常见的敲门砖麻雀虽小但模型、视图、模板、后台、查询优化这些核心点全都有特别适合没写过正式项目的新手也适合拿来做课程设计或毕设的参考模板。1. 项目定调先想清楚做一个什么样的博客1.1 需求拆解把博客拆成最小可用闭环做一个博客最怕一上来就堆功能。我见过有人刚开项目就想做评论审核、RSS订阅、邮件通知、用户积分结果连文章发布都还没跑通。我自己做这个项目时只保留了四条核心需求文章能写、能改、能删能分草稿和已发布状态。展示按时间倒序列出文章点进详情页看全文。归类文章有分类和标签能按分类看列表。互动访客可以在文章下面留言。需求清单列完你会发现这不只是“写个博客”而是把一个最小可用闭环跑通。什么叫闭环用户在浏览器输入网址看到文章列表点开文章在评论区留言这些数据要存进数据库管理员登录后台发布文章文章要立刻出现在前台。这两条链路合起来就是全栈开发的完整流程。也就是说这个项目真正要解决的核心问题是如何把“数据如何来”“数据如何存”“数据如何展示”三件事串起来而Django恰好把这三件事都内置好了。本轮实现MVP以后可以扩展文章发布、编辑、删除文章置顶、定时发布分类、标签标签云、相关推荐访客评论评论审核、回复通知后台管理文章站内搜索、归档页、草稿箱1.2 技术选型为什么偏偏用Django做全栈为什么这个项目非Django不可拿Flask、Node.js的Express甚至Spring Boot对比一下。Flask很灵活但什么都要自己组装数据库操作用SQLAlchemy表单用WTForms后台管理几乎没有现成的登录认证也得自己写或接第三方库。Express同理前端框架还要自己选。Spring Boot很强大但对新手来说环境复杂度太高光是一个Maven依赖和Java生态就够折腾半天。Django胜在“全家桶”自带ORM、Admin后台、表单处理、模板引擎、用户认证五个常用能力开箱即用。你不用去拼积木只要把业务逻辑写进去就能跑起来。更重要的是它的“约定优于配置”。Django的项目结构是固定的一套规范models写在models.py视图写在views.py路由写在urls.py模板放templates目录。刚开始看起来死板但好处是极其利于学习和交流。你随便打开一个别人的Django项目几分钟就能定位到关键代码在哪。学完这一套再换其他Web框架你会发现很多设计思路都是相通的。数据库选型上开发阶段直接用SQLite零配置、单文件、跟着项目走部署阶段再考虑PostgreSQL。这一点我特别想提醒新手不要一上来就装MySQL。不是MySQL不好而是本地配置MySQL本身就会消耗很多心力用户名密码、字符集、权限一整套下来还没开始写代码就被环境配置劝退了。SQLite够支撑一个中小型博客的起步流量。2. 环境准备与项目初始化从零开始不踩坑2.1 安装Python与虚拟环境给项目一个干净的运行区先确认本机Python版本。我建议用Python 3.10以上因为Django 4.2和5.x都对3.10支持得很好。终端里输入python3 --version和pip3 --version检查一下确保pip是可用的。然后创建虚拟环境mkdir django-blog cd django-blog python3 -m venv venv source venv/bin/activate # Windows环境用 venv\Scripts\activate pip install django4.2,5.1虚拟环境这一步太重要了。很多人直接在系统Python里pip install装了一堆包过几天换项目发现版本互相冲突又不敢随便卸载最后环境一团糟。venv把当前项目的依赖全部隔离在项目目录里想清干净直接删掉这个文件夹就行非常方便。如果你的电脑上同时有Python 3.8和3.11虚拟环境还可以精确绑定你要的版本互不干扰。安装完验证一下python -m django --version。如果能看到4.2.x或5.x的版本号就说明装成功了。版本选择上我推荐4.2 LTS这是长期维护版本官方会持续修bug到2026年左右5.x虽然新但一些第三方库可能还没跟上新手选LTS更稳遇到问题搜到的答案也多。2.2 创建项目和App目录一次建对后面省心进入项目目录执行django-admin startproject config . python manage.py startapp blog注意startproject config后面这个点。点表示当前目录直接作为项目根目录这样目录层次保持扁平后面引用manage.py和配置文件都方便。如果你漏了那个点会多套一层目录虽然也能跑但结构很别扭后面部署配置路径时容易出错。startapp blog生成了blog这个应用文件夹里面有models.py、views.py、admin.py等文件。这里要理解Django里“项目”和“应用”的区别项目config是整个网站的配置集合它管着全局的settings、根路由和WSGI入口应用blog是具体的业务模块以后你还可以增加accounts、comments等其他应用。一个项目由多个app组成每个app负责一块独立业务。一个常见困惑是为什么不把所有模型都堆在config/models.py里我见过新手把代码全写在一个地方后面想拆的时候非常痛苦。Django世界里一个业务一个app是默认约定别偷懒。blog这个app现在就专门放文章、分类、标签、评论这些博客核心模型。2.3 修改settings语言、时区、App注册一个都不能少打开config/settings.py第一件事就是把blog注册进INSTALLED_APPSINSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, blog, # 加上这一行 ]如果不注册后面的数据库迁移、admin管理、模板加载都找不到这个应用。我见过好几个人卡在这一步模型写好了但migrate时提示No changes detected最后发现就是忘了把app加进INSTALLED_APPS。然后是语言和时区设置LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_TZ True这里有个重要细节如果把USE_TZ保持TrueDjango存入数据库的时间是UTC标准时间Asia/Shanghai只是给模板渲染时用的默认展示时区。有些新手以为改了TIME_ZONE用datetime.now()取时间时却发现还是UTC其实正确做法是使用django.utils.timezone.now()获取当前时间或者让模板来自动转换展示时间。然后配置静态文件目录。建议在项目根目录创建一个static文件夹并在settings.py末尾加上STATIC_URL /static/ STATICFILES_DIRS [BASE_DIR / static]以后放CSS、JS、图片都统一放这个全局static目录后台和前端页面都能引用到。最后跑一次迁移并启动服务器验证python manage.py makemigrations python manage.py migrate python manage.py runserver浏览器打开127.0.0.1:8000看到火箭页面说明项目骨架已经搭好。这里补充一句第一次migrate之后数据库里会出现一堆表别慌这是Django自带的auth、admin、session等模块需要的数据表正常现象。3. 数据模型设计博客的地基3.1 模型字段选型每个字段都不是随便写的博客的核心模型我设计了四个Post文章、Category分类、Tag标签、Comment评论。分类和标签看起来差不多为什么都要这个区别很多新手搞不清楚。用一个生活例子解释你去书店一本书会放在某个分类书架上比如“计算机”分类但同一个架子上还可能贴着“畅销”“新书”这样的标签。一本书通常只有一个分类但可以有多个标签。对应到Django里分类用外键ForeignKey一篇文章属于一个分类标签用多对多ManyToManyField一篇文章可以挂多个标签。这种业务差异直接决定了模型字段的类型。Post模型参考代码from django.conf import settings from django.db import models class Category(models.Model): name models.CharField(分类名, max_length50, uniqueTrue) slug models.SlugField(别名, max_length60, uniqueTrue) class Meta: verbose_name 分类 verbose_name_plural 分类 def __str__(self): return self.name class Tag(models.Model): name models.CharField(标签名, max_length50, uniqueTrue) slug models.SlugField(别名, max_length60, uniqueTrue) def __str__(self): return self.name class Post(models.Model): STATUS_CHOICES ( (draft, 草稿), (published, 已发布), ) title models.CharField(标题, max_length200) slug models.SlugField(别名, max_length200, uniqueTrue) body models.TextField(正文) excerpt models.TextField(摘要, blankTrue) category models.ForeignKey(Category, on_deletemodels.PROTECT, related_nameposts, verbose_name分类) tags models.ManyToManyField(Tag, blankTrue, related_nameposts, verbose_name标签) author models.ForeignKey(settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, verbose_name作者) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultdraft) views models.PositiveIntegerField(浏览数, default0) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] verbose_name 文章 verbose_name_plural 文章 def __str__(self): return self.title逐个解释字段的用途。slug字段用来生成友好URL比如文章标题是“Django全栈开发入门”自动生成的slug可能是django-quan-zhan-ru-men这样文章地址是/post/django-quan-zhan-ru-men/比/post/12/更可读也更利于搜索引擎收录。excerpt是摘要列表页只显示摘要而不加载整篇正文能省不少数据库流量和页面渲染时间。status用choices限制状态可选值防止脏数据混入。views用来记录浏览量后面做热门文章排序时直接用这个字段。这里特别要讲的是on_deletemodels.PROTECT这个选择。分类外键如果改成默认的CASCADE删除分类时该分类下所有文章会连带一起删除。比如你把“Django教程”这个分类删掉里面十几篇文章瞬间全没了连确认提示都没有。PROTECT的作用是如果还有文章引用这个分类数据库会直接拒绝删除这个分类必须先处理完关联的文章。对于内容类系统来说这种保护机制非常重要。作者外键用CASCADE则是因为用户注销时其发布内容一并清理往往是可接受的。评论区模型留到第8章详细展开这里只需要知道它有外键关联回Post即可。3.2 迁移操作把模型变成数据库表的正确姿势模型写好后执行python manage.py makemigrations blog python manage.py migratemakemigrations生成迁移文件它做的事情是把当前模型的变更“翻译”成一个Python文件记录这次改了哪些字段、加了几张表migrate负责把迁移文件实际执行到数据库。一个是“生成变更方案”一个是“执行变更方案”两个命令必须搭配使用。现在很多新手一上来就只跑migrate等到模型改了但数据库没变才开始困惑其实问题就出在少了makemigrations这一步。迁移常见的坑里有三个最典型提示No changes detected检查blog是否在INSTALLED_APPS里或者你是不是改的是其他app的模型但命令写成了makemigrations blog。新增一个非空字段且没有给默认值Django会在终端里问你“已有记录怎么填这个字段”这时候填一个临时值或者退出检查模型定义。迁移文件千万别手动删。我见过有人为了“清干净数据库”直接删迁移文件再makemigrations结果Django发现模型状态和迁移历史对不上报一堆InconsistentMigrationHistory错误。正确重置方式是删掉db.sqlite3文件再重新migrate或者用迁移重置工具。迁移完成之后建议执行一下python manage.py dbshell看看数据库里的真实表结构能直观地理解每个模型如何映射成表。对SQLite比较熟的话也可以直接用工具打开db.sqlite3文件查看。这一步对理解ORM的映射逻辑特别有效。模型里还有一个细节ordering [-created_at]这个Meta选项指定了默认查询顺序按创建时间倒序排列。它写在模型层意味着不管哪个视图去查文章默认都会带这个排序省得到处写order_by。如果某个视图需要特殊顺序再单独覆盖即可。4. 业务逻辑视图、路由与查询的串联4.1 视图函数怎么搭列表页与详情页的设计思路视图是整个Django里最核心的环节它接收浏览器发送的HTTP请求处理业务逻辑再返回HTTP响应。博客系统里我写了三个视图文章列表、文章详情、分类列表。文章列表视图from django.core.paginator import Paginator from django.shortcuts import render from .models import Post def post_list(request): posts Post.objects.filter(statuspublished).select_related(category).prefetch_related(tags).order_by(-created_at) paginator Paginator(posts, 5) page_number request.GET.get(page) page_obj paginator.get_page(page_number) return render(request, blog/post_list.html, {page_obj: page_obj})这段代码核心点是filter(statuspublished)只把已发布的文章捞出来草稿永远不会出现在前台页面。order_by(-created_at)按创建时间倒序最新文章排最前。Paginator把完整数据集切成每页5条这里5条是我测试时的习惯具体几篇看你的排版需求。get_page方法比get_page_or_404更宽容page参数写了999就自动返回最后一页写了abc就返回第一页不会报错非常适合列表页这种场景。文章详情视图from django.shortcuts import get_object_or_404, render from .models import Post def post_detail(request, pk): post get_object_or_404(Post.objects.select_related(category), pkpk, statuspublished) post.views 1 post.save(update_fields[views]) return render(request, blog/post_detail.html, {post: post})get_object_or_404是Django提供的便捷函数它帮你做了两件事按条件查文章查不到就抛出404错误页面。不用自己手写try/except Http404的逻辑代码干净很多。这里有个细节为什么在get_object_or_404里还要带select_related(category)因为详情页模板大概率会显示文章所属分类提前JOIN一次避免模板里再触发一次额外查询。这个优化放在视图层比在模板层做更规范。浏览量自增这一小段有讲究。如果直接写post.views 1; post.save()Django会更新所有字段SQL语句会带上title、body、category等一系列字段更新开销大用update_fields[views]明确告诉它只更新views这一列生成更精简的UPDATE语句。如果博客流量变大更进一步的优化是使用F表达式from django.db.models import F Post.objects.filter(pkpk).update(viewsF(views) 1)这个写法把加法操作下推到数据库层面执行避免了“先查询再累加再写回”的并发竞态问题两个请求同时到达也不会丢计数。新手可以先理解第一种写法后面再精进。4.2 URL路由命名、传参与作用域细节路由是URL分发器。在blog目录下新建urls.py文件写入from django.urls import path from . import views app_name blog urlpatterns [ path(, views.post_list, namepost_list), path(post/int:pk/, views.post_detail, namepost_detail), path(category/slug:category_slug/, views.category_list, namecategory_list), ]然后在config/urls.py中用include把博客路由挂进来from django.urls import include, path urlpatterns [ path(admin/, admin.site.urls), path(, include(blog.urls)), ]app_name blog给这套路由加了一个命名空间。这么做的最大好处是模板里引用链接时写成{% url blog:post_detail post.pk %}以后就算把post_detail函数改名为article_detail只需要在urls.py里改一处name所有模板自动生效不用满项目翻找替换。命名空间还解决了多个app里存在同名name时路由冲突的问题。路由系统里新手最容易犯的错是参数顺序和类型转换器不对。int:pk里的int是一个转换器它只匹配纯数字路径保证传进视图的pk一定是整数。如果写成pk这种不带转换器的写法传进来的就是字符串get_object_or_404虽然会自动转但养成习惯用int:pk更严谨。slug转换器只匹配字母、数字、连字符和下划线正好对应文章slug字段的格式。分类列表视图补充一下def category_list(request, category_slug): category get_object_or_404(Category, slugcategory_slug) posts Post.objects.filter(categorycategory, statuspublished).select_related(category).order_by(-created_at) return render(request, blog/post_list.html, {page_obj: posts, category: category})注意这里复用post_list.html模板只是把数据换成了当前分类下的文章。模板层面靠一个{% if category %}判断就能显示不同的标题省了一整套分类专用模板这种复用的思路在项目里很常见。5. 模板与前端页面用户看到的每一个细节5.1 模板继承与公共布局避免复制粘贴HTMLDjango模板引擎最让人舒服的设计是继承机制。以前写PHP项目每个页面要么复制粘贴导航栏和页脚要么用include引入结构一乱整个页面分崩离析。Django的思路是先建一个base.html作为骨架子模板只改写content块。base.html大致结构!DOCTYPE html html langzh-hans head meta charsetutf-8 title{% block title %}我的博客{% endblock %}/title link relstylesheet hrefhttps://cdn.staticfile.org/bootstrap/5.3.0/css/bootstrap.min.css /head body nav classnavbar navbar-expand-lg navbar-dark bg-dark a classnavbar-brand href{% url blog:post_list %}我的博客/a /nav main classcontainer mt-4 {% block content %}{% endblock %} /main /body /html模板里直接引用CDN的Bootstrap来撑起页面样式这完全是正常操作。Django负责的是服务端逻辑前端样式框架用什么全凭自己喜好用现成的CSS框架能把精力集中在核心业务上不用非要手写一套样式才算“全栈”。等页面结构稳定了再慢慢自定义成自己的风格。文章列表页post_list.html{% extends base.html %} {% block content %} h1 classmb-4最新文章/h1 {% for post in page_obj %} article classcard mb-3 div classcard-body h2a href{% url blog:post_detail post.pk %}{{ post.title }}/a/h2 p classtext-muted{{ post.category.name }} · {{ post.created_at|date:Y-m-d }}/p p{{ post.excerpt|default:post.body|truncatechars:100 }}/p /div /article {% empty %} p还没有文章/p {% endfor %} {% if page_obj.has_previous or page_obj.has_next %} nav aria-labelPage navigation ul classpagination {% if page_obj.has_previous %} li classpage-itema classpage-link href?page{{ page_obj.previous_page_number }}上一页/a/li {% endif %} li classpage-itemspan classpage-link第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页/span/li {% if page_obj.has_next %} li classpage-itema classpage-link href?page{{ page_obj.next_page_number }}下一页/a/li {% endif %} /ul /nav {% endif %} {% endblock %}这里值得展开讲模板过滤器的链式用法。{{ post.excerpt|default:post.body|truncatechars:100 }}的执行顺序是先取post.excerpt的值看看是不是空字符串如果是空就降级用post.body再对最终结果截断到100个字符。过滤器按从左到右的顺序依次执行一个的输出变成下一个的输入。这种链式写法能省掉很多模板里的if判断。|date:Y-m-d控制日期格式Y是四位年份m是两位月份d是两位日期。{% empty %}是for循环里很贴心的语法功能等于“列表为空时显示这段”省去额外的if判断。分页导航里page_obj.previous_page_number和next_page_number直接由Paginator输出模板层完全不用手算页码。5.2 正文显示为什么不要随便用safe过滤器文章详情页里正文显示是个安全关键点。最稳妥的写法是div classpost-body {{ post.body|linebreaksbr }} /divlinebreaksbr会把纯文本里的换行符转换成br标签同时对HTML标签保持转义。也就是说即使有人在正文里写了scriptalert(xss)/script浏览器也只会把它当作普通文本显示出来不会解析执行。这是防止存储型XSS攻击的重要关卡。如果写成{{ post.body|safe }}效果就完全不同了safe等于告诉模板“这段文本百分百安全直接输出原始HTML”。一旦数据库里的正文混入恶意脚本浏览器执行后可以偷走用户Cookie甚至篡改页面。有些教程为了省事喜欢用safe显示Markdown渲染结果但这是有风险的。如果你真想在博客里支持Markdown正确做法不是直接safe而是用第三方库把Markdown转成HTML后再清洗。简单说三步保存时用mistune之类库把Markdown编译成HTML展示前用bleach库过滤掉HTML里危险的标签和属性最后才在模板中输出。中间那一步清洗不能省否则等于把用户输入直接当成HTML渲染。新手阶段先老老实实用linebreaksbr把换行显示做好后面需要Markdown再按这三步来这是我在实际项目中踩过坑才总结出来的经验。模板里还有一个常见小问题CSS和JS文件引用不到。如果页面样式全丢先检查模板顶部有没有{% load static %}标签再检查文件是否真的在static目录下最后看settings里STATICFILES_DIRS是否配置了正确路径。这三步排查完90%的静态文件问题都能解决。6. 管理后台两行代码获得内容管理系统6.1 注册模型与后台字段定制默认后台其实很糙Django Admin是我最服气的能力之一它几乎是白送的内容管理系统后台。在blog/admin.py里注册模型from django.contrib import admin from .models import Category, Comment, Post, Tag admin.site.register(Category) admin.site.register(Tag) admin.site.register(Post) admin.site.register(Comment)然后启动python manage.py runserver访问127.0.0.1:8000/admin你会看到Django自带的登录界面——用户名和密码用createsuperuser命令创建。登录之后不用写任何页面就能在这个后台增删改查文章、分类和评论这已经是很多小型内容管理系统做不到的强度了。但默认的后台列表非常粗糙文章就显示一行字符串没有筛选、没有搜索找一篇历史文章得翻好几页。所以我建议给Post配一个ModelAdmin类admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display (title, category, author, status, views, created_at) list_filter (status, category, created_at) search_fields (title, body) prepopulated_fields {slug: (title, )} date_hierarchy created_at list_editable (status, category)逐个说明这些配置项的作用。list_display控制列表页显示哪些列比默认的字符串展示信息量大得多list_filter会在页面右侧出现筛选区点击status就能快速筛选草稿或已发布search_fields启用顶部搜索框按标题和正文内容搜索文章prepopulated_fields是个神器你输入标题的同时slug字段会自动根据标题生成不用手写一串英文别名这对中文内容来说尤其省心date_hierarchy在列表页顶部生成一个按日期下钻的导航条查看某一天发的文章非常快list_editable让status和category直接在列表页下拉修改不用点进详情页对于批量调整文章状态特别有用。6.2 后台操作进阶批量置顶、一键发布与作者显示后台还支持自定义操作。默认的“删除所选”操作对文章管理来说粒度太粗如果想让选中的多篇文章一次性变成“已发布”可以在admin.py里加一个动作admin.action(description设为已发布) def make_published(modeladmin, request, queryset): queryset.update(statuspublished) class PostAdmin(admin.ModelAdmin): actions [make_published]这里queryset.update()是批量更新方法直接写进数据库不会触发模型的save()方法所以千万注意别在里面塞需要save才能完成的逻辑。更新完成后后天列表页的操作下拉框里会多出“设为已发布”全选一堆草稿文章下拉选择它再点执行就全部发布了比逐条修改快不是一点半点。后台列表里还有一个很容易踩的小坑默认的author列显示的是User object (1)这类对象字符串很难看。可以通过一个自定义方法解决def author_name(self, obj): return obj.author.username author_name.short_description 作者然后把这个方法加进list_display里list_display (title, category, author_name, status, views, created_at)short_description的作用是给这个列指定一个中文表头否则Django会显示方法名author_name。这种方法自定义列在后台管理中很常用比如可以根据views字段加一个颜色标签也可以根据status给行加CSS类修改都非常灵活。7. 查询与删除对象ORM最容易被忽略的实操点7.1 查询优化N1问题、select_related与prefetch_related博客列表页最容易踩的坑是N1查询问题。它在模板循环里是这样暴露的{% for post in page_obj %} p{{ post.category.name }}/p {% for tag in post.tags.all %}{{ tag.name }}{% endfor %} {% endfor %}第一眼看上去只是访问文章的分类和标签但Django的ORM是懒加载的。post.category在你访问它之前不会真的去查数据库一旦模板访问了它ORM才发起SQL查询。于是问题来了页面有10篇文章主查询1次每篇文章访问category触发1次、访问tags.all又触发至少1次总共跑了1 10 10 21条SQL。这就是典型的N1问题。解决方案就是视图里那两行posts Post.objects.filter(statuspublished).select_related(category).prefetch_related(tags)select_related用于外键和一对一关系它通过SQL的JOIN操作在第一次查询时就把分类表的数据一并取回来之后模板访问post.category时内存里已经有了不会再发SQL。prefetch_related用于多对多关系它另外发起一次批量查询把所有相关标签按文章ID分组一次取回。用上这两个方法之后整个列表页通常只需要3条左右SQL一条查文章、一条JOIN分类、一条批量查标签。页面响应速度提升肉眼可见数据库压力也小很多。想验证优化效果可以装一个django-debug-toolbar它会显示每个页面的SQL执行次数和耗时。也可以自己在视图里打印connection.queries对比优化前后的SQL条数。实测下来当文章数量从几十涨到几百时优化前后的差距会越来越大所以从一开始就养成写select_related的习惯很重要。7.2 删除对象单删、批量删、级联删与软删除“django执行查询-删除对象”是个高频搜索词我把它系统拆成四种场景很多新手只学会了一种就以为会了。第一种删除单个对象。post Post.objects.get(pk1) post.delete()先查询出来再调用delete()方法适合明确知道要删哪一篇文章的场景。delete()会返回一个元组例如(3, {blog.Post: 1, blog.Comment: 2})意思是一共删了3条记录1篇文章、2条关联评论。这个返回值很有用可以用来写日志或做统计。第二种批量删除。Post.objects.filter(statusdraft).delete()filter之后直接调用delete()不写循环。注意一个关键特性QuerySet.delete()不会调用模型的delete()方法也不会触发pre_delete/post_delete信号。如果你在模型里重写了delete来做清理工作比如删除文章封面图片文件、记录删除日志批量删除时这些逻辑会静默跳过。这一点极其容易被忽略我项目里就吃过这个亏模型delete方法里写了删除图片文件的逻辑测试单删没问题后来用批量清理接口清数据时图片文件残留了一堆。第三种外键关联的级联删除。category Category.objects.get(pk1) category.delete()分类外键用的是CASCADE时删掉这个分类会连带把下面所有文章一起删除。我在第3章特意把分类改成PROTECT就是防止这种事故。但评论外键用CASCADE是合理的评论属于文章的附属数据文章没了评论没有任何保留价值。这里看重的是业务关系删除“父”数据时“子”数据是否还要存在想清楚这个问题on_delete参数就不会选错。第四种软删除。class Post(models.Model): is_deleted models.BooleanField(已删除, defaultFalse)软删除不真正移除数据库记录只把is_deleted置为True。博客文章尤其适合这种方案哪天想恢复旧文章很方便官方说“回收站”也是这个思路。实现软删除后每次查询都要记得过滤掉已删除数据所有地方写filter(is_deletedFalse)很繁琐可以用自定义Manager封装from django.db import models class PostManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(is_deletedFalse) class Post(models.Model): objects PostManager() all_objects models.Manager() # 保留原始管理器之后Post.objects.filter(...)默认只查未删除文章Post.all_objects可以连已删除的一起查。对于要恢复内容的后台管理页用all_objects前台展示用objects互不干扰。这个模式在真实项目里使用频率非常高强烈建议掌握。7.3 统计与分页用annotate做热门标签和文章计数分页器除了get_page还有一点实用细节模板里可以用page_obj.paginator.count获取总文章数。比如页面显示“共23篇文章”就是这一条属性的事。如果想做热门标签云用annotate Countfrom django.db.models import Count hot_tags Tag.objects.annotate(post_countCount(posts)).filter(post_count__gt0).order_by(-post_count)这段代码会计算每个标签关联的文章数只保留有文章关联的标签按文章数倒序。Count(posts)里的posts来自于模型里related_nameposts的ManyToManyField。没有这个related_nameannotate里写的名称会不一样。这种统计是Django ORM里最常用的组合后面做分类文章排行、月度归档都能复用同一套写法。查询优化的最后补充一句Django的values和values_list可以按需取字段例如只需要文章标题和slug时用Post.objects.values(title, slug)避免把整个body文本字段都读进内存。这在做下拉列表、选择框这类轻量展示时非常管用。8. 评论功能让博客真正“活”起来8.1 评论模型与表单评论审核可以先放一放博客写出来没人说话总感觉少点什么加上评论功能也是全栈项目里“能写数据”的典型场景。评论模型class Comment(models.Model): post models.ForeignKey(Post, on_deletemodels.CASCADE, related_namecomments) name models.CharField(昵称, max_length30) content models.TextField(评论内容, max_length500) created_at models.DateTimeField(评论时间, auto_now_addTrue) class Meta: ordering [created_at] verbose_name 评论 verbose_name_plural 评论 def __str__(self): return f{self.name} 评论了 {self.post}外键设置到Post并指定related_namecomments意思是这个外键的反向关系名叫comments。这样文章详情页里可以直接post.comments.all()取到该文章的所有评论不用自己再按post_id去查。on_delete用CASCADE评论作为附属信息随文章删除而消失这是合理的数据关系设计。表单用Django forms声明式写from django import forms from .models import Comment class CommentForm(forms.ModelForm): class Meta: model Comment fields (name, content) widgets { name: forms.TextInput(attrs{class: form-control, placeholder: 你的昵称}), content: forms.Textarea(attrs{class: form-control, placeholder: 说点什么吧}), }widgets属性的作用是把渲染HTML时的class和placeholder这些属性预先写好模板里渲染表单时就会自动带上不用手写一堆input标签。这比手动拼HTML表单省心太多同时还能自动处理字段校验、错误信息展示这些琐碎环节。这里我先不做评论审核机制。不是没必要而是MVP阶段先把“能提交、能展示”跑通审核逻辑可以在管理后台里通过状态字段扩展。一口气塞太多需求容易把自己绕晕。8.2 视图与模板联动POST处理、CSRF防护与表单回显详情页视图要升级为同时处理GET和POST两种请求from django.shortcuts import get_object_or_404, redirect, render from .forms import CommentForm from .models import Post def post_detail(request, pk): post get_object_or_404(Post, pkpk, statuspublished) comments post.comments.all()[:20] form CommentForm() if request.method POST: form CommentForm(request.POST) if form.is_valid(): comment form.save(commitFalse) comment.post post comment.save() return redirect(blog:post_detail, pkpost.pk) context { post: post, comments: comments, form: form, } return render(request, blog/post_detail.html, context)form.save(commitFalse)这条非常关键。它先把用户提交的name和content组装成一个Comment实例但先不写入数据库把post赋值给这个评论后再最终save。这样做的好处是表单里不用把post_id作为一个隐藏字段传回来避免用户改URL参数把自己评论挂到别的文章上。POST处理成功后马上redirect回详情页这是防止重复提交的经典做法——刷新页面时浏览器重新发的是GET请求不会把刚才的POST再发一遍。模板里的表单部分form methodpost classmt-4 {% csrf_token %} {{ form.as_p }} button typesubmit classbtn btn-primary提交评论/button /form{% csrf_token %}不能少Django默认开启CSRF中间件POST请求不带这个token会直接返回403。它本质上是一个随机token写入表单后提交时由中间件校验确保请求确实来自你渲染的这个页面而不是跨站伪造请求。这个防护是框架默认给的新手千万不要去settings里把它关掉。{{ form.as_p }}是渲染表单的快捷方式它会按段落包裹每个字段并自动带上之前widgets里配置的class和placeholder。如果之后想手工控制每个字段的布局可以逐个字段写div classmb-3 label昵称/label {{ form.name }} /div但第一步直接用as_p就够了。评论列表渲染时注意内容转义模板里{{ comment.content|linebreaksbr }}而不是safe原理和第5章讲的一样防XSS。9. 常见问题排查速查表把实操里的坑一次性填平把实操中真正高频遇到的问题整理成一张表方便大家直接对照解决。现象可能原因排查与解决NoReverseMatch错误URL的name写错、命名空间写漏、参数个数不对对比urls.py里的name和模板中的{% url %}引用检查app_name作用域名makemigrations提示No changes detectedapp没注册进INSTALLED_APPS打开settings.py确认blog在列表里后台显示英文、中文乱码没设置语言settings里LANGUAGE_CODE zh-hans时间总是差8小时时区与USE_TZ理解不到位设置TIME_ZONE Asia/Shanghai模板展示时自动转换静态文件404STATICFILES_DIRS配置错误或模板缺load static检查settings路径、模板顶部{% load static %}表单提交返回403缺csrf_token模板form内加{% csrf_token %}详情页404但数据明明存在状态不是published或过滤条件不匹配检查statuspublished条件是否满足post.save()执行很慢每次都全量更新所有字段用save(update_fields[...])或update()批量删除后自定义清理逻辑没执行QuerySet.delete不触发模型delete方法改用信号或逐条删除再分享三个排查思路。第一Django在DEBUGTrue时报错页面信息非常详细红字部分会直接提示哪个文件和哪一行不要只盯着最后那句话看往上扫几屏通常能找到根因。第二遇到500错误先看终端里的Traceback重点看最后“django”开头的几行那才是真正抛错的地方。第三很多数据“诡异”的变化先怀疑缓存和中间件顺序再怀疑自己的业务逻辑——中间件写错导致多个请求串数据的事情并不少见。10. 部署上线从本机到公网的最后一公里本地跑通不是结束我见过太多项目在runserver里欢快运行一到部署就崩。至少要知道部署的基本链路Django项目 Gunicorn Nginx或Whitenoise静态文件服务 数据库可选PostgreSQL。先把settings.py改成生产模式DEBUG False ALLOWED_HOSTS [www.example.com, localhost]DEBUGFalse之后原来那个详细报错页面会变成通用500页面任何异常细节都不会暴露给普通访客。这一步是安全底线因为DEBUGTrue时页面会打印完整Traceback甚至包含数据库连接信息。ALLOWED_HOSTS指定允许访问的域名列表不在列表里的Host直接拒绝请求是防HTTP Host头攻击的基础配置。然后安装部署所需依赖并收集静态文件pip install gunicorn whitenoise python manage.py collectstaticcollectstatic会把所有app的静态文件包括Django后台的CSS和JS统一收集到STATIC_ROOT指向的目录。如果不跑这一步线上打开后台会发现样式全丢登录页面丑得没法看。Whitenoise这个库的作用是在WSGI应用层面直接服务静态文件对于中小型项目省掉单独配Nginx静态文件目录的麻烦。运行方式改成Gunicorngunicorn config.wsgi:application --bind 0.0.0.0:8000config.wsgi:application指向Django项目的WSGI入口Gunicorn通过这个入口把HTTP请求交给Django处理。Nginx作为反向代理在前端把80端口的请求转发给8000的Gunicorn同时可以直接处理图片等大文件静态资源。这一套链路里Nginx管静态文件和转发Gunicorn运行Django应用职责分明配合使用就是经典的Python Web部署架构。数据库方面如果博客真实流量不高SQLite其实也能扛住一段时间。但正式环境建议迁移到PostgreSQLDjango这边只需要改settings里的DATABASES配置模型代码和视图逻辑一行都不用动这就是ORM抽象层的价值。数据迁移可以用python manage.py dumpdata导出JSON再在目标环境用loaddata导入这是最简单的路径。最后关于备份写一个最简单的cron定时备份数据库文件也比什么都不做强。博客是内容资产数据库是命根子。这句话你等丢失过一次文章数据就会懂不用问我是怎么知道的。写到这里Django博客系统的全栈链路就梳理完了。最后一次给新手朋友一句心里话博客系统这个项目别做一遍就扔有条件就真正部署一个博客出来哪怕每天只有自己访问能发文章、能看到线上日志、能被搜索引擎收录你学到的东西和本地跑通完全两回事。我第一次做完这个项目之后又把分类、标签、评论的代码照着重写了一遍第二遍明显比第一遍顺很多——Django就是这样一个靠练习滚熟的框架坑踩一遍、再修一遍才是真正的掌握。