Django全栈开发入门:手把手教你从零搭建博客系统 📅 发布时间:2026/9/9 16:18:59 👁 浏览次数: 我最近帮一个朋友从零搭博客系统他用的是Node.js折腾了一周还在纠结用哪个框架。我问他需求是什么他说就是写写文章、发发代码、偶尔有人评论。我说那你换个思路我用Django一个周末就能给你跑起来带后台带部署。他不信结果真做完了。这篇就把我当时搭博客系统的完整过程、踩过的坑、以及为什么这么选型全部整理出来。Django全栈开发入门用博客系统这个经典场景来走一遍新手可以直接照着抄作业有基础的也能看看我在各个关键节点的取舍逻辑。1. 为什么选Django做博客一次全栈选型的真实心路1.1 博客系统最需要的是快不是炫聊技术选型之前先说个可能有点反直觉的观点个人博客是所有Web应用里对技术栈要求最低、但对开发效率要求最高的类型之一。为什么因为博客核心就三件事——内容录入、内容展示、内容互动。没有复杂业务流没有高并发也不需要实时推送。你需要的不是强大的框架而是能让你把精力放在写字这件事上的框架。我当时对比过几个方案Node.js的Express、Next.js、Golang的Gin、Python的Django和Flask。Express和Gin确实轻量灵活但灵活的另一面是什么都要自己搭。博客系统看着简单真做起来要处理的东西一点不少管理员登录、文章增删改查、数据模型设计、评论系统、分页、后台管理界面。用Express这些全都得自己找库拼装。而Django官方文档的开篇就是for perfectionists with deadlines——为有截止日期的完美主义者准备。这话说得挺准它最擅长的就是帮你在短时间内把常规功能做得规规矩矩。1.2 Django自带后台是隐藏的加速器很多新手不知道Django有一个很多人低估的功能admin后台。你创建完数据模型注册到admin里它就自动生成一套完整的增删改查管理界面带登录认证、权限管理、分页搜索。这个功能对博客系统来说简直是量身定制的。传统方案里你要么自己写一个后台管理页面要么引入现成的后台管理框架要么连table plus数据库管理工具一起用。但我实测下来Django admin对博主这个使用场景刚好合适不需要额外的前端工作界面虽然不是特别好看但胜在功能齐全、开箱即用。我只需要把自己定义的字段、搜索项、筛选项注册进去一个够用的管理后台就出来了。这一步省下的时间在我整个项目里占了至少三分之一。1.3 全栈开发的边界你要掌握到什么程度标题里有个词叫全栈开发这里我得说清楚我的理解。Django全栈指的是后端 模板页面 数据库这一条链路由Python统一承担而不是说你要用Django写JavaScript。实际项目中页面的交互效果、异步加载、复杂动效还是需要前端三件套HTML、CSS、JavaScript来配合。所以全栈对Django项目来说核心是你能独立打通这条链路模型定义数据 → 视图处理请求 → 模板渲染页面 → 表单接收交互 → 数据库存取数据 → 部署上线。这个闭环打通了你就具备了一个人做完整小项目的能力。博客系统恰好是体验这个闭环最平滑的切入点——它没有复杂业务逻辑但每个环节都会碰到一次刚好建立全局认知。2. 从零搭建项目环境准备与骨架工程2.1 虚拟环境与依赖版本的选择先强调一个我见过无数新人踩的坑不要拿系统全局的Python直接装Django。你电脑上可能同时有多个项目每个依赖的Django版本可能不同全局安装很容易导致版本冲突最后升级一个包把另一个项目搞挂了。正确姿势是给每个项目开独立虚拟环境。我是这样操作的# 创建项目目录 mkdir django_blog cd django_blog # 创建虚拟环境Python 3.10 python3 -m venv venv # 激活虚拟环境Windows和macOS/Linux命令不同 # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate # 安装Django这里用当前稳定版本 pip install django有一点值得说明我实际用的是Django 4.2 LTS版。为什么强调LTS因为Django的长期支持版本会持续提供安全更新而博客系统一旦部署上线安全更新就是长期的事。Django 4.2支持到2026年对生产项目来说比追最新版5.x更稳妥。你可以用pip show django查看当前安装版本。环境搭好后强烈建议先在虚拟环境里确认一下Python和Django的版本很多奇怪的报错都源于版本不匹配python -m django --version2.2 创建项目骨架后先做这几件事创建项目和第一个Appdjango-admin startproject config . python manage.py startapp blog这里我用了config作为项目名配合后面的.意思是在当前目录生成项目配置文件。很多教程让你用mysite之类的名字我建议用config因为项目配置和业务代码分开目录结构更清晰。启动后的目录结构大致是这样django_blog/ ├── config/ │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── blog/ │ ├── migrations/ │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── models.py │ ├── tests.py │ └── views.py ├── manage.py └── venv/你可能会问为什么博客这种小项目也要拆出App因为Django的设计理念是一个App只干一件事。博客系统虽然只有一个业务域但如果你把文章、评论、用户全部塞在同一个文件里项目变大后会非常痛苦。先把App的结构建对后面加功能就是加App的事而不是在原来代码里面堆。2.3 settings.py里必须改的几处配置创建完项目第一件事不是写代码而是把settings.py的基础配置理顺。我列几个必改项每个都带原因ALLOWED_HOSTS。这个配置决定哪些域名/主机名可以访问你的站点。开发阶段你要加上localhost、127.0.0.1后面部署时再把你的真实域名加进去。如果这里配置不对访问会直接报Bad Request (400)。ALLOWED_HOSTS [localhost, 127.0.0.1]INSTALLED_APPS注册。把新创建的blog加进去顺便加上Django自带的几个常用应用默认就有别删INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, blog, # 新增 ]语言和时区。默认是英文和UTC我们改成中文和国内时区LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_TZ TrueUSE_TZTrue在Django 4.0以后是默认开启的它表示数据库里存的时间都是UTC时间显示时再转成本地时区。刚开始会觉得麻烦但等你的博客有海外读者时就会感谢这个设计了。模板里显示时间要用{{ article.created_at|localtime }}才能正确转换。数据库配置。开发阶段直接用默认的SQLite就行零配置文件型数据库非常适合博客这种读写量不高的场景。等真遇到性能瓶颈再换PostgreSQLDjango的ORM会帮你把这种切换成本压到最低。2.4 就跑一次迁移确认链路通畅配置改完后先做一次迁移让Django自带的用户、session、admin这些数据表全部建好python manage.py migrate python manage.py createsuperuser python manage.py runserver然后访问http://127.0.0.1:8000/admin/用刚才创建的超级用户登录。能看到admin登录页到这里你的Django骨架工程就算活了。这一步千万别跳很多人上来就写模型结果跑起来才发现基础配置有问题排查起来反而更费时间。3. 博客核心数据模型文章、分类、标签与评论3.1 模型设计的三种关联关系博客系统的数据模型可以说是教科书级的关联关系习题四种基本关系中它占了三种外键、多对多、一对一扩展用户时用。我设计的是四个模型Article文章、Category分类、Tag标签、Comment评论。先看核心代码# blog/models.py from django.db import models from django.contrib.auth.models import User from django.utils.text import slugify from django.urls import reverse class Category(models.Model): name models.CharField(分类名, max_length50) slug models.SlugField(标识, uniqueTrue, blankTrue) class Meta: verbose_name 分类 verbose_name_plural verbose_name def save(self, *args, **kwargs): if not self.slug: self.slug slugify(self.name) super().save(*args, **kwargs) def __str__(self): return self.name class Tag(models.Model): name models.CharField(标签名, max_length50) slug models.SlugField(标识, uniqueTrue, blankTrue) def save(self, *args, **kwargs): if not self.slug: self.slug slugify(self.name) super().save(*args, **kwargs) def __str__(self): return self.name class Article(models.Model): title models.CharField(标题, max_length200) slug models.SlugField(URL标识, uniqueTrue, nullTrue, blankTrue) category models.ForeignKey( Category, verbose_name分类, on_deletemodels.PROTECT, related_namearticles ) tags models.ManyToManyField(Tag, verbose_name标签, blankTrue, related_namearticles) author models.ForeignKey(User, verbose_name作者, on_deletemodels.CASCADE) content models.TextField(正文) is_published models.BooleanField(是否发布, defaultFalse) views_count 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 verbose_name def __str__(self): return self.title def get_absolute_url(self): return reverse(blog:detail, kwargs{slug: self.slug}) class Comment(models.Model): article models.ForeignKey(Article, verbose_name文章, on_deletemodels.CASCADE, related_namecomments) name models.CharField(昵称, max_length50) email models.EmailField(邮箱, blankTrue) content models.TextField(评论内容) is_approved models.BooleanField(是否通过, defaultFalse) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: ordering [created_at] verbose_name 评论 verbose_name_plural verbose_name def __str__(self): return f{self.name}: {self.content[:20]}3.2 每个字段选择背后的逻辑把代码贴出来以后我逐个解释关键设计决策ForeignKey使用on_deletemodels.PROTECT。当你删除一个分类时如果有文章还在引用它数据库会阻止删除并抛出ProtectedError。为什么不用默认的CASCADE因为博客分类一旦误删连带文章全没了那是灾难级失误。PROTECT逼着你先处理关联数据再删分类适合这种删除要谨慎的场景。评论和文章的关系我反而用了CASCADE——文章删了评论留着也没意义一起清掉合理。ManyToManyField用blankTrue。文章可以没有标签但必须要有分类。这是内容管理的常见规则分类是博客的组织结构标签是附加描述。related_name一定要设置。默认你通过article.comment_set访问反向关联语义不清晰。设置了related_namecomments后可以写article.comments.all()读代码的人一眼就明白。这个习惯在大型项目里极其重要它直接体现了模型关系的语义层设计。Meta.ordering [-created_at]。全局给文章设默认排序保证后续所有查询文章的地方默认都是最新的在前。避免你每次查询都要写order_by少写一行是一行但前提是你清楚默认排序是有全局副作用的——比如后台列表也会跟着变一般这正是你想要的。SlugField配合自定义save()。文章的URL我用slug而不是数字ID这样链接看起来是/post/django-fullstack-blog/而不是/post/42/对SEO更友好也方便读者通过URL猜内容。slug字段允许为空保存时自动根据标题生成英文标识标题里有空格替换成短横杠、特殊字符去掉、统一小写。中文标题生成的slug会是空的所以我在后台管理时通常手动填一个英文slug。3.3 迁移策略什么时候makemigrations模型写好后执行python manage.py makemigrations blog python manage.py migrate一个关键经验每改一次模型立刻生成迁移并执行不要攒着一起做。Django的迁移文件是顺序执行的攒多了容易产生迁移冲突而且你很难回忆起当时为什么要加这个字段。我还建议给每个迁移文件写清意的messagepython manage.py makemigrations blog --name add_comment_model迁移文件一旦生成就不要去手动编辑它——它是历史记录不是当前状态的描述。要改模型结构正确做法是再生成一个新的迁移。3.4 注册Admin后台先让内容录入跑起来模型写好不等于系统可用先注册Admin后台才能往里手动录入文章数据这是开发阶段最直接的验证模型是否正确的方式# blog/admin.py from django.contrib import admin from .models import Article, Category, Tag, Comment admin.register(Article) class ArticleAdmin(admin.ModelAdmin): list_display (title, category, author, is_published, created_at) list_filter (is_published, category, tags) search_fields (title, content) prepopulated_fields {slug: (title,)} date_hierarchy created_at autocomplete_fields [] # 需要字段支持暂时留空 admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display (name,) admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display (name,) admin.register(Comment) class CommentAdmin(admin.ModelAdmin): list_display (name, article, is_approved, created_at) list_filter (is_approved, created_at) actions [approve_comments] admin.action(description通过选中的评论) def approve_comments(self, request, queryset): queryset.update(is_approvedTrue)这里有个细节prepopulated_fields在浏览器里会根据标题自动填充slug字段这是Django admin自带的小功能开发效率提升很明显。而list_display、list_filter、search_fields这三个配置决定了你后台筛选、搜索、列表展示的体验强烈建议从一开始就配好不然文章多起来后台会很难用。登录admin手动创建几篇测试文章。注意is_published暂时勾选上因为后面前台列表要调过滤逻辑。到这一步你已经可以在后台管理内容了数据库的表结构也经过了实际写入验证。接下来才是真正把内容吐到前台页面的重头戏。4. 视图、URL路由与模板渲染让文章真正显示出来4.1 从request到response的完整链路很多人学Django先背MTV模式但背完还是不知道浏览器输入网址后发生了什么。我用大白话把这个链路讲清楚浏览器发请求 → Django按URL路由规则匹配到对应的视图函数 → 视图函数从数据库取数据 → 把数据通过模板渲染成HTML → 返回给浏览器。在这一套链路里视图函数只是中间人核心功绩是组装数据。写视图最忌讳的是脑子里没有这条链路只盯着函数本身。心里有了全貌遇到问题才能定位到是哪一环出了问题。4.2 列表页与详情页的视图编写先配置路由。Django 4.0之后推荐用path()我也建议使用path()加int:pk、slug:slug这种类型转换器比正则表达式的re_path()直观太多# config/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), path(, include(blog.urls)), ]# blog/urls.py from django.urls import path from . import views app_name blog urlpatterns [ path(, views.article_list, namehome), path(post/slug:slug/, views.article_detail, namedetail), ]注意app_name blog这是命名空间。后面不管你在模板还是视图里都通过reverse(blog:detail, kwargs{slug: article.slug})生成URL。这样做的好处是你哪天改了URL的路径规则不用逐个改模板和视图命名空间会自动解析到新的路径。视图函数我放在了blog/views.pyfrom django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import Article, Comment def article_list(request): articles Article.objects.filter(is_publishedTrue).select_related(category, author) paginator Paginator(articles, 5) # 每页5篇 page_number request.GET.get(page) page_obj paginator.get_page(page_number) context { page_obj: page_obj, latest_articles: Article.objects.filter(is_publishedTrue).order_by(-created_at)[:5], } return render(request, blog/article_list.html, context) def article_detail(request, slug): article get_object_or_404(Article.objects.select_related(category, author), slugslug, is_publishedTrue) comments article.comments.filter(is_approvedTrue) context { article: article, comments: comments, } return render(request, blog/article_detail.html, context)这里有几个优化点值得解释select_related()。当你在模板里遍历文章然后访问article.category.name时每篇都会再执行一次查询。select_related(category, author)会在首次查询时通过SQL JOIN把关联数据一次性取出来。博客文章列表一页就几篇这个优化感知不明显但它是必须养成的ORM习惯——往深了说这就是性能优化里最重要的N1查询问题的解法。get_object_or_404()。文章不存在时返回404页面而不是让你手动try/except。我见过不少新手写Article.objects.get()然后自己处理DoesNotExist代码又长又容易漏。用Django提供的快捷函数更符合约定优于配置的哲学。分页器为什么返回page_obj而不返回articles。Paginator从articles里切出当前页的数据后page_obj上既包含当页数据page_obj.object_list也包含上一页/下一页页码等分页信息。模板里就可以这样渲染分页导航了。4.3 模板继承与快速美化前端部分可能是很多后端开发最头疼的。我的策略是不手写复杂样式直接用Bootstrap 5。但注意我教程里演示的重点是模板继承不是Bootstrap本身。先建base.html放公共骨架!-- templates/base.html -- !DOCTYPE html html langzh-hans head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title{% block title %}我的博客{% endblock %}/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body nav classnavbar navbar-expand-lg navbar-dark bg-dark div classcontainer a classnavbar-brand href{% url blog:home %}我的博客/a /div /nav main classcontainer mt-4 {% block content %} {% endblock %} /main footer classtext-center text-muted py-4 © 2024 我的博客 /footer /body /html然后在文章列表页继承它只需要关心自己那一块的content块!-- templates/blog/article_list.html -- {% extends base.html %} {% block title %}最新文章 - 我的博客{% endblock %} {% block content %} h1 classmb-4最新文章/h1 {% for article in page_obj %} article classcard mb-3 div classcard-body h2 classh5 a href{{ article.get_absolute_url }}{{ article.title }}/a /h2 p classtext-muted small mb-2 {{ article.category.name }} · {{ article.author.username }} · {{ article.created_at|localtime|date:Y-m-d H:i }} /p p classcard-text{{ article.content|truncatechars:150 }}/p /div /article {% empty %} p还没有发布文章。/p {% endfor %} nav aria-label分页 ul classpagination {% if page_obj.has_previous %} li classpage-itema classpage-link href?page{{ page_obj.previous_page_number }}上一页/a/li {% endif %} li classpage-item activespan 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 {% endblock %}这里有个很实用的模板过滤器{{ article.content|truncatechars:150 }}。列表页只显示正文前150个字符详情页才显示全文。这个过滤器是Django自带的会自动截断到最近的词边界比单纯切片要优雅得多。而{{ article.created_at|localtime|date:Y-m-d H:i }}这个链式过滤器就是时区适配的显示层方案。4.4 分页器最容易踩的坑分页器有个坑我特别想提醒当URL里带其他查询参数时?page2会把它覆盖掉。比如你的列表页有搜索关键词?qdjango点击第2页链接就变成?page2搜索词丢了这是新手排查半天都找不到原因的问题。解决方案是手动拼接参数或者用一个自定义的get_sorted_posts_view配合querydict处理。另一个坑是分页时配合filter的先后顺序。必须先对QuerySet执行过滤filter(is_publishedTrue)再交给Paginator。反过来做分页器切出来的是所有文章的数据过滤只作用于第一页内部逻辑就乱套了。把列表页、详情页都跑通之后最后一个关键模块是评论交互。这是博客从单向输出变成双向交流的分水岭也是Django表单机制的绝佳练习场景。5. 评论系统实战表单处理与用户交互5.1 表单类的两种写法与校验逻辑博客评论的核心诉求是用户填昵称、邮箱、评论内容提交后存到数据库管理员审核通过后展示。Django表单和Django模型表单两种写法我用的是ModelForm因为我们的数据模型已经定义好了少写一遍字段声明# blog/forms.py from django import forms from .models import Comment class CommentForm(forms.ModelForm): class Meta: model Comment fields [name, email, content] widgets { name: forms.TextInput(attrs{class: form-control, placeholder: 你的昵称}), email: forms.EmailInput(attrs{class: form-control, placeholder: 邮箱选填}), content: forms.Textarea(attrs{class: form-control, rows: 4, placeholder: 写下你的评论...}), } labels { name: 昵称, email: 邮箱, content: 评论内容, }Meta.widgets里的class: form-control是配合Bootstrap样式的关键。如果不加生成的HTML是裸的input样式会很丑。这也是ModelForm的一个小技巧用widgets把CSS类绑定到表单控件上前端美化就不用改模板了。labels定义中文标签因为模型里verbose_name已经设置过这里可以省略。5.2 评论数据如何与文章关联保存表单本身不持有文章信息提交的字段里也没有article。那评论怎么关联到文章答案藏在视图里def article_detail(request, slug): article get_object_or_404(Article, slugslug, is_publishedTrue) if request.method POST: form CommentForm(request.POST) if form.is_valid(): comment form.save(commitFalse) comment.article article comment.save() return redirect(blog:detail, slugarticle.slug) else: form CommentForm() comments article.comments.filter(is_approvedTrue) context { article: article, comments: comments, form: form, } return render(request, blog/article_detail.html, context)这行form.save(commitFalse)是关键中的关键。它的意思是先不写数据库把表单数据组装成一个Comment实例返回让我有机会给实例设额外字段这里是comment.article article然后再真正保存。如果你图省事直接form.save()会报NOT NULL constraint failed之类的外键错误因为article是必填外键表单里又没有这个字段。评论新增了一个状态字段is_approved默认False。也就是说新评论不会立刻出现在页面上要等管理员在后台审核通过。这是博客系统必备的垃圾评论防护第一步。5.3 刷新防重复提交的Post/Redirect/Get模式评论提交成功后我用了redirect而不是直接render。这里涉及一个Web开发里几乎每个新手都会踩的坑表单提交后直接渲染页面用户按F5刷新浏览器会重新提交POST请求评论就重复入库了。POST/Redirect/GETPRG模式就是解法POST处理完数据后返回一个302重定向浏览器自动GET到详情页。此时按F5只会重新GET页面不会重新POST。我见过太多人评论系统做好了一刷新就多一条评论其实就是少了这一步。这个模式同样适用于文章发布表单、任何涉及数据写入的场景。5.4 CSRF保护与常见报错Django默认开启了CSRF中间件模板里所有POST表单必须带上{% csrf_token %}否则会报403 Forbidden。这个报错应该是新手接触Django表单时最经典的问题之一。在详情页的评论表单里要注意放在form内部form methodpost {% csrf_token %} {{ form.as_p }} button typesubmit classbtn btn-primary提交评论/button /form{{ form.as_p }}会把表单字段渲染成用p包裹的格式配合Bootstrap的表单样式还够用。如果你想更精细控制每个字段的布局可以手动遍历{{ form }}。对于博客评论场景as_p足够了不要为了炫技术把模板搞得太复杂。除了CSRF评论表单还有一个很隐蔽的问题提交空内容。ModelForm不会自动对CharField加非空校验如果你的模型里content字段没有blankTrue表单校验时字段为空会报这个字段是必填项这其实够了。但如果你的name字段设置成了blankTrue想允许匿名就得注意表单里要配上requiredTrue之类的显式条件。我的方案是昵称和评论内容都必填邮箱选填这样既兼顾匿名交流的需要也能保证评论区基本质量。到这里博客的核心功能——写文章、展示文章、收评论——已经全部能用了。但能开发和能上线是两码事。很多项目中跑得好好的部署到服务器上就各种怪问题下面这部分是我最想让你在动手部署前看完的。6. 部署到服务器的关键几步与踩坑记录6.1 静态文件收集与WhiteNoise本地开发时Django的开发服务器runserver会帮你自动处理静态文件CSS、JS、图片。但一旦进入生产模式Django官方态度很明确它不负责服务静态文件。生产环境必须由Web服务器或静态文件服务来处理。你的博客如果用了Bootstrap CDN这个问题还不明显。但如果你有自定义CSS、上传的图片、favicon那这些文件全都得处理。我的做法是引入WhiteNoise它可以让Django在生产模式下直接服务静态文件不用额外配置复杂的CDN或独立静态服务器。# settings.py STATIC_URL static/ STATIC_ROOT BASE_DIR / staticfiles # 收集后存放的目录 MEDIA_URL media/ MEDIA_ROOT BASE_DIR / media # WhiteNoise需要先 pip install whitenoise MIDDLEWARE [ django.middleware.security.SecurityMiddleware, whitenoise.middleware.WhiteNoiseMiddleware, # 放在这里 # ... 其他中间件 ] STORAGES { staticfiles: { BACKEND: whitenoise.storage.CompressedManifestStaticFilesStorage, }, }然后执行收集静态文件命令python manage.py collectstatic这个命令会把所有App和项目里的静态文件复制到STATIC_ROOT目录WhiteNoise会在运行时从那里读取。如果你漏了这一步生产环境页面的样式会全部丢失。6.2 DEBUGFalse之后冒出来的几个隐藏坑本地开发默认DEBUGTrue部署前改成DEBUGFalse这一改通常会带出三个隐藏问题第一个ALLOWED_HOSTS必须配好。之前提到过DEBUGFalse时如果ALLOWED_HOSTS为空或不包含你的域名访问直接400。把域名或服务器IP加进去比如ALLOWED_HOSTS [yourdomain.com, www.yourdomain.com, 你的服务器IP]。第二个静态文件全部404。原因见上一节没有collectstatic或者没有配WhiteNoise中间件页面就是纯裸奔的丑HTML。第三个admin后台CSS丢失。这个问题最容易忽略。admin自带的CSS也是静态文件如果你只collectstatic但没配WhiteNoiseadmin页面同样会没有样式。我当初就是先发现前台正常、后台全白排查半天才意识到所有静态文件都没走对。6.3 Gunicorn Nginx的基础配合从Django层面生产环境运行要用WSGI服务器不能用runserver。Gunicorn是Python世界最主流的WSGI服务器配合Nginx做反向代理和静态文件缓存是Python项目的经典组合。在服务器上操作的核心命令pip install gunicorn gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3解释一下这个命令config.wsgi:application是Django项目暴露给WSGI服务器的入口--workers 3表示启动3个工作进程静态文件请求则由Nginx在更前面直接接管。然后Nginx的配置里把请求转发给Gunicornserver { listen 80; server_name yourdomain.com; location /static/ { alias /path/to/your/project/staticfiles/; } location /media/ { alias /path/to/your/project/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里注意proxy_pass指向的是Gunicorn监听的地址Nginx把请求转过去静态文件则完全由Nginx直接读取不经过Django性能好很多。这两个服务的关系可以理解成Nginx是前台的接待员负责引导和静态资源Gunicorn和Django才是真正干活的员工。6.4 宝塔面板的简化部署思路我上面写的是纯命令行方案适合想搞懂原理的人。如果你图省事或者对Linux命令行不熟那用宝塔面板确实能省不少事。宝塔里的Python项目管理器可以直接安装Python版本、创建项目、管理Gunicorn进程界面化操作比命令行直观很多。思路是一致的项目文件传上去 → 配置Python环境 → 装依赖 → 迁移数据库 → collectstatic → 启动Gunicorn → 在宝塔的Nginx配置里加反向代理。原理你只要理解了6.1到6.3这几节面板操作无非是这些步骤的图形化版本。部署完还有一个经常被忽视的问题如何让服务在服务器重启后自动启动。命令行Gunicorn在SSH断开后会被杀掉所以生产环境一定要配置systemd服务或者用宝塔的守护功能。简单systemd配置的思路是定义ExecStart启动GunicornRestartalways保证进程挂了自动拉起。如果你用宝塔这部分也是面板帮你搞定的。6.5 安全配置里容易被忽略的三个点部署不是能访问就行了博客也是面向公网的安全是最不该省的事。我每次做教育类Python项目都会先把下面三个基础安全项配掉SECRET_KEY绝不能提交到公开仓库。用环境变量读入SECRET_KEY os.environ.get(DJANGO_SECRET_KEY, 开发用默认值)。SESSION_COOKIE_SECURE True和CSRF_COOKIE_SECURE True要求浏览器只在HTTPS连接下传输cookie。如果还没配置HTTPS先别开否则登录会掉线。启用django.middleware.security.SecurityMiddleware自带的SECURE_SSL_REDIRECT和X_FRAME_OPTIONS等响应头防止基本的跨站点击劫持攻击。这些配置本身不复杂但很多人因为觉得我又不是什么大网站谁会攻击我就把安全跳过了。结果就是网站被扫描机器人扫到漏洞沦为别人顺手牵羊的对象。博客再小也是公网服务安全底线这几件事花不了半小时。写在最后的几个实用经验做完这个博客系统我最大的感触是Django让你把想清楚的时间花在刀刃上。我见过太多人用极简框架写博客最后在数据库设计和管理后台这些非核心但必须的事情上浪费了大量精力。我自己后来用这个项目模板给朋友升级成了带RSS订阅、全文搜索、文章置顶的版本加功能的体验非常顺滑——这就是Django生态成熟度的红利。如果你要把这个项目继续扩展我个人会建议按这三个方向走给文章加Markdown渲染替换掉原来模板里的纯文本正文加一个search视图用数据库自带的icontains做全文搜索够用且简单再给评论加上验证码虽然不优雅但是挡垃圾评论真实有效。博客系统的价值在于持续写作而不是折腾框架。能让你的写作流程最顺畅的技术栈就是最好的技术栈。Django在这一点上对我的使用习惯来说确实是最省心的选择。