☰
Django全栈开发入门:从零构建博客系统的完整实战教程
2026/10/5 2:49:26 网站建设 项目流程

如果你问我,新手学全栈开发第一个项目做什么最划算,我的答案从来只有一个:博客系统。别看现在短视频教程满天飞,Django写博客这个组合至今依然是性价比最高的入门路径。一个博客系统几乎涵盖了你以后做任何Web应用都要用的核心能力——数据建模、增删改查、用户登录、模板渲染、分页列表、权限控制,每一项都是硬通货。把这些练熟了,你再去碰电商、CRM、内容管理系统,会发现底层的套路几乎一样。

这篇博文,我准备带你从零开始,搭一套能真正跑起来的Django博客系统。不是那种只贴代码让你抄的教程,我会把每一步“为什么这么做”也讲清楚:为什么要用虚拟环境、为什么外键要设计成CASCADE、为什么POST请求忘了加csrf_token就报403、为什么上线前要改ALLOWED_HOSTS。这些东西看着琐碎,但正是区分“会照抄”和“真会做”的分水岭。内容会一直走到上线前要避开的坑为止,代码我全部手写验证过,你可以直接照着敲。

1. 项目整体设计与思路拆解

1.1 为什么博客系统是新手全栈开发的最佳练手项目

很多人一上来就想做电商平台、社交软件,我的建议是先冷静。功能越复杂的项目,前期挫败感越强,一个404都能让你怀疑人生。博客系统的妙处在于“麻雀虽小,五脏俱全”:它有列表页、详情页、分类、标签、作者登录、后台管理、分页查询,还能顺手扩展搜索和评论,几乎覆盖了Web开发的全部基础知识点。

更重要的是,博客系统的数据关系非常直观。一篇文章有一个作者,属于一个分类,可以打多个标签——这就是典型的一对多和多对多关系,恰好能把Django ORM里外键和中间表的用法全部练到。你做完之后,脑子里的CRUD思维就固化了:任何业务系统,本质都是对数据的增删改查再加上一层权限控制。先搞定这个,后面学DRF、Celery、Docker这些东西都水到渠成。

1.2 技术选型:版本、数据库与为什么不选Flask

技术栈我推荐:Python 3.10以上 + Django 4.2 LTS + SQLite起步。Django 4.2是当前的长周期支持版本,官方提供安全维护到2026年左右,学习期间不用担心大版本升级带来的兼容问题。Python选3.10或3.11都行,3.10以上的语法特性足够用,环境也稳定。

为什么选Django而不是Flask?不是Flask不好,而是Flask太“自由主义”了。你用Flask写博客,要自己装数据库ORM、自己接登录组件、自己拼表单处理,光是选型就要研究半天,这对新手是巨大的隐性成本。Django则是“全家桶”路线:ORM、Admin后台、认证系统、表单处理全部内置,你只需要专注写业务逻辑。用交通工具类比,Flask像自行车,自由但所有零件自己配;Django像带齐全家桶的房车,重量大但开箱就能走。对于“全栈开发入门”这个目标来说,开箱即用比轻量更重要。

数据库直接用默认的SQLite。别一上来就上PostgreSQL或MySQL,SQLite是文件型数据库,零配置、零维护、跨平台,开发调试效率极高。等你部署上线时,因为所有数据库操作都走了ORM层,把settings里的数据库配置改成PostgreSQL就行,业务代码一行不用动。这就是ORM带来的最大红利。

1.3 功能范围与目录结构规划

我建议第一版只做核心闭环,别把评论、搜索、点赞全部塞进来。功能一多,你会陷入无休止的调试而学不到本质。第一版做这五个功能就够:

  • 文章列表页:按发布时间倒序展示,带分页
  • 文章详情页:展示标题、作者、分类、标签和正文
  • 分类筛选页:按分类过滤文章
  • 登录/注销:使用Django内置认证系统
  • 发布文章:仅登录用户可用,支持写标题、正文、选分类

项目目录结构我习惯这样组织,看起来清爽,也方便后续扩展app:

mysite/ # 项目配置目录 settings.py urls.py wsgi.py blog/ # 博客应用 models.py views.py urls.py admin.py templates/ # 全局模板目录 base.html blog/ index.html detail.html post_form.html registration/ login.html static/ # 静态文件目录 css/ js/ manage.py

记住一个原则:一个项目里可以有多个app,比如blog只管文章,以后想加个留言板,就再新建一个guestbook app。所有与业务无关的公共模板和静态文件放在项目根目录,方便全局复用。

2. 环境准备与项目初始化

2.1 虚拟环境:隔离是第一原则

我见过太多新手把Django装进系统Python里,过两个月装了一堆乱七八糟的包,版本冲突到崩溃。虚拟环境的本质就是给你的项目造一个独立的小房间,房间里的Python版本和第三方包只归这个项目管,互不干扰。

创建和激活的命令,Linux/macOS和Windows略有区别:

# 创建虚拟环境,会在当前目录生成 venv 文件夹 python3 -m venv venv # Linux/macOS 激活 source venv/bin/activate # Windows 激活 venv\Scripts\activate

激活后,命令行前面会出现(venv)前缀,说明你现在处于虚拟环境中。这时候再装依赖就安全了:

pip install django==4.2.*

装完可以验证一下版本:

python -m django --version

输出4.2.x就说明环境OK。新手最容易犯的错误是忘记激活虚拟环境就敲命令,导致django-admin找不到、或者pip装到了全局环境。每次打开新终端窗口,先看一眼有没有(venv)前缀,养成习惯。

2.2 创建项目和app:startproject与startapp的职责分工

环境准备好后,用到两个命令:

# 创建项目 django-admin startproject mysite # 进入项目目录 cd mysite # 创建博客应用 python manage.py startapp blog

这里经常有人犯迷糊:startproject和startapp到底啥区别?我的理解是:项目是整个网站的“总发动机”,负责全局配置(数据库、中间件、路由入口);app是挂在发动机上的“功能模块”,比如blog就是专门处理文章业务的模块。一个项目可以挂很多app,就像一台服务器可以跑很多服务,模块之间职责分明。

创建完app后,先去blog/models.py里写模型,但先别急,我们得把app注册到项目里。打开mysite/settings.py,在INSTALLED_APPS列表里加一行:

INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'blog', # 注册我们的博客应用 ]

不注册的话,Django感知不到这个app的存在,后面的数据迁移和admin管理全都用不上。

接着启动开发服务器验证一下:

python manage.py runserver

浏览器访问http://127.0.0.1:8000/,看到火箭起飞页面就说明项目框架搭好了。遇到8000端口被占用,可以指定端口,比如python manage.py runserver 8001。

2.3 基础配置:语言、时区和静态文件

开发服务器跑起来后,第一件事是修改settings里的语言、时区和静态文件配置。默认配置是英文和UTC时间,这对中文博客不友好,每次显示时间都差8小时。

LANGUAGE_CODE = 'zh-hans' TIME_ZONE = 'Asia/Shanghai' USE_TZ = True

LANGUAGE_CODE = 'zh-hans'是为了让Admin后台和Django默认错误信息显示中文,亲测体验好很多。TIME_ZONE = 'Asia/Shanghai'把时区设到东八区,USE_TZ = True表示数据库里存的时间统一为UTC,展示时按Asia/Shanghai转换。这套配置是Django官方推荐做法,新手不要为了省事直接设USE_TZ = False,否则以后接第三方API时时间会比较混乱。

静态文件配置也顺手加上:

STATIC_URL = 'static/' STATICFILES_DIRS = [ BASE_DIR / 'static', ]

然后在项目根目录建一个static文件夹。STATIC_URL是浏览器访问静态文件时的URL前缀,STATICFILES_DIRS告诉Django去哪找项目级的静态资源。加上之后,模板里才能用{% load static %}加载CSS和图片。

3. 核心数据模型设计与ORM实操

3.1 模型设计:文章、分类、标签与作者的关系

这是整个项目的核心。打开blog/models.py,我来写博客系统最经典的四个模型:分类、标签、文章,以及复用Django内置的User作为作者。

from django.db import models from django.contrib.auth.models import User from django.utils import timezone from django.urls import reverse class Category(models.Model): name = models.CharField('分类名称', max_length=50, unique=True) slug = models.SlugField('URL标识', unique=True) class Meta: verbose_name = '分类' verbose_name_plural = '分类' def __str__(self): return self.name class Tag(models.Model): name = models.CharField('标签名称', max_length=20, unique=True) class Meta: verbose_name = '标签' verbose_name_plural = '标签' def __str__(self): return self.name class Post(models.Model): title = models.CharField('标题', max_length=200) content = models.TextField('正文') category = models.ForeignKey( Category, verbose_name='分类', on_delete=models.CASCADE, related_name='posts' ) tags = models.ManyToManyField(Tag, verbose_name='标签', blank=True, related_name='posts') author = models.ForeignKey( User, verbose_name='作者', on_delete=models.CASCADE, related_name='posts' ) created_at = models.DateTimeField('创建时间', default=timezone.now) updated_at = models.DateTimeField('更新时间', auto_now=True) is_published = models.BooleanField('是否发布', default=True) class Meta: verbose_name = '文章' verbose_name_plural = '文章' ordering = ['-created_at'] def __str__(self): return self.title def get_absolute_url(self): return reverse('blog:detail', kwargs={'pk': self.pk})

重点解释几个容易踩坑的设计决策:

第一,外键on_delete=models.CASCADE的含义是“级联删除”。也就是说,删除一个分类,该分类下所有文章会被一并删除。这在入门阶段是省事的设计,但在真实项目里,这个参数选择必须非常谨慎。如果以后做电商系统,你绝不想因为删了一个商品分类而把几千条订单记录也删掉。到时候可以换成PROTECT(受保护,有外键引用时禁止删除)或SET_NULL(外键置空,前提是字段设了null=True)。

第二,related_name必须养成制定习惯。没有它,默认在分类对象上用category.post_set访问文章列表;有了它,可以直接写category.posts.all(),语义清晰得多。

第三,default=timezone.now和auto_now=True的区别一定要记牢。前者是“创建时取当前时间”,后者是“每次保存都刷新为当前时间”。用反了就乱套,比如把更新时间设成default,那每次编辑文章时间都不会变化。

第四,get_absolute_url是Django老手约定俗成的写法,定义它之后,在Admin后台和模板里都能通过post.get_absolute_url()直接拿到详情页地址,不用到处拼URL。

3.2 迁移流程:makemigrations与migrate的正确姿势

模型写完后,它不是马上生效的,需要两步操作:

# 根据模型生成迁移文件 python manage.py makemigrations # 将迁移文件应用到数据库 python manage.py migrate

makemigrations相当于把模型变更“拍快照”生成一个迁移文件,这个文件就像一个补丁脚本,记录了数据库怎么做变更。migrate才真正执行补丁、创建表和字段。很多人第一次接触会搞混,我的类比方法是:makemigrations是写代码,migrate是运行代码。

还有一个字符串字段的细节。slug字段我初始定义没有给max_length,Django 4.x里SlugField默认是50长度。如果你给文章生成了中文标题,slug最好自己生成拼音或英文标识,中文直接存进去会变成URL编码,体验很差。分类和标签的slug字段会用来做SEO友好的URL,比如/category/python/而不是/category/3/。

迁移文件相当重要,不要随便删。新手常见的翻车现场是:模型改来改去,数据库迁移乱了,干脆把所有迁移文件删了重新makemigrations,结果migrate报错说表已存在。正确做法是:每次模型变更都正常生成迁移文件提交进版本库,数据库和迁移文件保持一一对应。万一真出问题,先python manage.py migrate app zero回滚,再重新迁移。

3.3 ORM增删改查:重点说说删除对象这件事

模型和数据库都就绪了,我们进入Django执行查询的关键实操。打开终端,进入Django的shell环境:

python manage.py shell

这个shell是我们调试ORM的最强工具,下面是一套完整的增删改查演示。

增:

from blog.models import Post, Category, Tag from django.contrib.auth.models import User user = User.objects.first() category = Category.objects.create(name='Python', slug='python') post = Post.objects.create( title='第一篇Django博客', content='这里是正文内容。', category=category, author=user, ) post.tags.add(Tag.objects.get_or_create(name='入门')[0])

查:

# 查全部文章 Post.objects.all() # 过滤:只查已发布的文章 Post.objects.filter(is_published=True) # 精确取一条,不存在会抛DoesNotExist异常 Post.objects.get(pk=1) # 统计数量 Post.objects.filter(category__name='Python').count()

改:

post = Post.objects.get(pk=1) post.is_published = False post.save()

重点来了:删除对象。ORM删除有几种做法,每个都有严格的使用场景。最直白的单条删除:

post = Post.objects.get(pk=1) post.delete()

批量删除:

# 删除所有未发布的文章 Post.objects.filter(is_published=False).delete()

这里必须给你泼一盆冷水:delete()是真实物理删除,一旦执行不可恢复。而且由于外键是CASCADE,删文章没问题,但如果删分类对象,下面所有文章都会被连带删除:

# 危险操作:删除分类,该分类下所有文章同时被删除 category = Category.objects.get(slug='python') category.delete()

我见过太多新人在生产环境干过这种事,删完大呼数据找不回来。所以更稳妥的方式是“软删除”:给Post加一个字段,比如is_active = models.BooleanField(default=True),删除时不执行delete(),而是把is_active置为False:

Post.objects.filter(pk=1).update(is_active=False)

查询时默认带上is_active=True的过滤条件,删除操作变成逻辑隐藏,随时可以恢复。电商平台、内容管理系统里几乎都在用这种方案,核心原因就是数据太重要,物理删除是最后手段。

实操心得:在执行任何删除之前,先查一下会波及多少条数据。方法很简单:

Post.objects.filter(category=category).count()

count()确认数量之后再动手。这种“先侦查后执行”的习惯,能让你避开90%的误删事故。

4. 视图、路由与模板:让页面真正跑起来

4.1 URL设计与视图函数:请求如何到达你的代码

项目的URL入口在mysite/urls.py,我们需要先注册blog自己的路由:

from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('', include('blog.urls')), ]

然后在blog应用里新建blog/urls.py:

from django.urls import path from . import views app_name = 'blog' urlpatterns = [ path('', views.index, name='index'), path('post/<int:pk>/', views.detail, name='detail'), path('category/<int:category_id>/', views.category_view, name='category'), ]

这里的<int:pk>是Django最常用的路径转换器,它会把URL里对应位置的值解析成整数传给视图函数。比如用户访问/post/3/,视图函数就会收到pk=3。用字符串做的话就是<slug:slug>,这里我们先用pk。

视图函数的逻辑,我建议入门阶段先全部用函数视图,不要一上来就上ListView、DetailView这些类视图。函数视图的请求→响应流程非常直观,对你理解Django核心机制帮助极大。等函数视图写熟了,再接触类视图的自动分页、自动表单处理,就能理解那些“魔法”背后的原理了。

写blog/views.py:

from django.shortcuts import render, get_object_or_404 from django.core.paginator import Paginator from .models import Post def index(request): post_list = Post.objects.filter( is_published=True, is_active=True, ).select_related('author', 'category').prefetch_related('tags') paginator = Paginator(post_list, 5) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'blog/index.html', {'page_obj': page_obj}) def detail(request, pk): post = get_object_or_404(Post, pk=pk, is_published=True, is_active=True) return render(request, 'blog/detail.html', {'post': post}) def category_view(request, category_id): post_list = Post.objects.filter( category_id=category_id, is_published=True, is_active=True, ) paginator = Paginator(post_list, 5) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'blog/index.html', {'page_obj': page_obj, 'category_id': category_id})

两个细节值得展开。第一,select_related和prefetch_related是解决N+1查询问题的手段。如果你在列表页循环展示文章,不预查询外键关系,每显示一篇文章都要向数据库发几次请求查作者、查分类,10篇文章就是几十次查询。加了这两个方法,Django会用JOIN合并成少量查询,数据量大时性能差距非常明显。

第二,get_object_or_404是“查不到就返回404”的简写,比你自己try-except写一遍干净得多。它还支持传多个过滤条件,比如pk=pk, is_published=True意味着即使URL手输一篇文章ID,只要它没发布,照样404。

4.2 模板继承与动态渲染:告别重复的HTML

Django的模板系统跟其他语言不同,它不是单纯插值,而是有一套自己的语法,比如{{ post.title }}是输出变量,{% if %}、{% for %}是逻辑控制。最强大的功能是模板继承。先写基础模板templates/base.html:

<!DOCTYPE html> <html lang="zh-hans"> <head> <meta charset="UTF-8"> <title>{% block title %}我的博客{% endblock %}</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> </head> <body> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" href="{% url 'blog:index' %}">我的博客</a> <div class="ms-auto"> {% if user.is_authenticated %} <span class="navbar-text text-white me-2">{{ user.username }}</span> <a href="{% url 'logout' %}" class="btn btn-outline-light btn-sm">注销</a> <a href="{% url 'blog:post_create' %}" class="btn btn-primary btn-sm">写文章</a> {% else %} <a href="{% url 'login' %}" class="btn btn-outline-light btn-sm">登录</a> {% endif %} </div> </div> </nav> <div class="container mt-4"> {% block content %}{% endblock %} </div> </body> </html>

子模板用{% extends "base.html" %}继承,再{% block content %}填充自己的内容:

{% extends "base.html" %} {% block title %}文章列表 | 我的博客{% endblock %} {% block content %} {% for post in page_obj %} <article class="card mb-3"> <div class="card-body"> <h2 class="card-title"> <a href="{% url 'blog:detail' post.pk %}" class="text-decoration-none">{{ post.title }}</a> </h2> <p class="text-muted small"> {{ post.created_at|date:"Y-m-d" }} · {{ post.author.username }} · {{ post.category.name }} </p> <p class="card-text">{{ post.content|truncatechars:120 }}</p> </div> </article> {% empty %} <p>暂时没有文章。</p> {% endfor %} {% if page_obj.has_other_pages %} <nav> <ul class="pagination"> {% if page_obj.has_previous %} <li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">上一页</a></li> {% endif %} <li class="page-item disabled"><span class="page-link">第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span></li> {% if page_obj.has_next %} <li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">下一页</a></li> {% endif %} </ul> </nav> {% endif %} {% endblock %}

几个关键点:{% url 'blog:index' %}是URL反转,即通过路由的name定位URL地址。这样做的好处是后端路由地址随便改,模板完全不用动。千万不要在模板里硬编码/post/3/这种路径,后患无穷。

过滤器语法也很实用。{{ post.created_at|date:"Y-m-d" }}把datetime格式化成年月日;{{ post.content|truncatechars:120 }}在正文里截取120个字符,超出部分用省略号。冒号后面就是过滤器参数。

细节提示:如果你发现 extends 后整个页面空白,十有八九是base.html里的block名称对不上。block名是连接父模板和子模板的桥梁,一个写content,一个写contents,就静默失败了。

4.3 分页查询:文章多了也不怕

分页用上一节视图里写到的Paginator直接搞定。Paginator(post_list, 5)表示每页显示5篇文章。page_obj是一个Page对象,它提供了has_previous、has_next、previous_page_number、next_page_number、number、paginator.num_pages等属性,全部在模板里以page_obj.xxx形式访问。

分页要处理的另一件事是查询参数。如果你在分类页分页,URL里已经有/category/3/了,加上?page=2之后变成了/category/3/?page=2,这没问题。但如果列表页还有其他筛选条件,比如?keyword=python&page=2,上一页下一页的链接需要把其他参数也带上,通常用一个自定义模板标签处理。第一版博客不需要搞这么复杂,但你要有这个概念:分页URL拼接不是把?page=简单加到末尾就完了。

分页代码写完后,去访问首页,你会看到文章能正常展示、能翻页了。到这里,博客系统的“读”功能已经完整,下面我们要做“写”和“权限”。

5. 登录认证与文章发布:从“游客”到“作者”

5.1 Django自带的认证体系:别重复造轮子

登录功能是博客系统的安全核心。Django的django.contrib.auth内置了完整的用户体系:User模型、密码哈希、会话管理、登录状态校验,全部开箱即用。你可能觉得“不就是登录吗”,但自己从头写一个安全的登录系统非常困难,密码加盐哈希、会话固定防护、CSRF这堆问题,任何一个做不好都会被攻破。

如果你没带过新手,你可能不知道:很多人为了“快速实现登录”自己写用户名密码存在数据库里,明文保存、没有会话管理、没有任何安全防护,这在生产环境就是裸奔。所以我的立场也非常明确:用Django自带的auth,这是标准答案,也是唯一推荐答案。

现在开始配置路由。在mysite/urls.py里,把登录注销的视图接到Django内置视图上:

from django.contrib.auth import views as auth_views urlpatterns = [ path('admin/', admin.site.urls), path('login/', auth_views.LoginView.as_view(), name='login'), path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('', include('blog.urls')), ]

这两行代码就实现了登录和注销的完整逻辑。LoginView会处理表单提交、密码校验、会话建立等所有动作,我们只需要提供一个模板给它。LogoutView更简单,点击注销就会清除会话并跳转到登录页。

5.2 自定义登录页面与权限控制

Django的LoginView默认去registration/login.html这个路径找模板,所以我们在templates/registration/目录下创建登录页:

{% extends "base.html" %} {% block title %}登录 | 我的博客{% endblock %} {% block content %} <div class="row justify-content-center"> <div class="col-md-6"> <h2 class="mb-4">登录</h2> <form method="post"> {% csrf_token %} {{ form.as_p }} <button type="submit" class="btn btn-primary w-100">登录</button> </form> <p class="mt-3"><a href="{% url 'admin:index' %}">管理员入口</a></p> </div> </div> {% endblock %}

form.as_p是Django表单的快捷渲染方式,把每个字段渲染成一个段落。登录成功后,Django默认跳转到LOGIN_REDIRECT_URL。我们在settings里配置一下:

LOGIN_REDIRECT_URL = '/'

这样登录完成后回到首页。还有一个细节:如果用户在未登录状态访问了需要登录的页面,Django会自动跳转到登录页,并在URL后面带?next=/原页面地址/。登录成功后,LoginView会自动读取next参数跳回去。这个机制我们模板里不用额外处理,因为form提交时next字段会自动包含在POST数据里。

权限控制的核心是@login_required装饰器。未登录用户访问被装饰的视图,会被重定向到登录页。在blog/views.py里加上:

from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect @login_required def post_create(request): if request.method == 'POST': title = request.POST.get('title') content = request.POST.get('content') category_id = request.POST.get('category') # 这里做最基本的校验 if title and content and category_id: Post.objects.create( title=title, content=content, category_id=category_id, author=request.user, ) return redirect('blog:index') categories = Category.objects.all() return render(request, 'blog/post_form.html', {'categories': categories})

这里要特别提醒一个新手常犯的安全错误:不要把author字段从表单POST数据里直接取值。作者信息应当从会话里取request.user,而不是用户自己在表单里提交。如果有人伪造POST请求把自己写成别人,就是一个越权漏洞。同理,任何涉及权限的操作,都要用后端当前登录用户来校验,前端传上来的用户ID一律不信任。

request.user是当前会话对应的用户对象。在模板里,我们也通过user.is_authenticated判断用户是否登录,并据此显示“写文章”按钮或“登录”按钮。

5.3 文章发布流程与后台管理

发布文章的模板post_form.html,注意一点:Django模板里只有一个{% csrf_token %}是不够的,它必须放在<form>标签内部。每次渲染页面时,Django会生成一个随机的CSRF令牌放在隐藏字段里;提交时,Django会比对请求中的令牌与会话中存的令牌。如果匹配失败,直接返回403拒绝请求。

{% extends "base.html" %} {% block title %}写文章 | 我的博客{% endblock %} {% block content %} <div class="row justify-content-center"> <div class="col-md-8"> <h2 class="mb-4">写文章</h2> <form method="post"> {% csrf_token %} <div class="mb-3"> <label class="form-label">标题</label> <input type="text" name="title" class="form-control" required> </div> <div class="mb-3"> <label class="form-label">分类</label> <select name="category" class="form-select"> {% for c in categories %} <option value="{{ c.pk }}">{{ c.name }}</option> {% endfor %} </select> </div> <div class="mb-3"> <label class="form-label">正文</label> <textarea name="content" rows="12" class="form-control" required></textarea> </div> <button type="submit" class="btn btn-primary">发布</button> </form> </div> </div> {% endblock %}

后台管理写起来可能比想象中要简单。打开blog/admin.py:

from django.contrib import admin from .models import Post, Category, Tag @admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display = ('title', 'author', 'category', 'created_at', 'is_published') list_filter = ('is_published', 'category', 'created_at') search_fields = ('title', 'content') date_hierarchy = 'created_at' @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): list_display = ('name', 'slug') @admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display = ('name',)

然后创建超级管理员账号:

python manage.py createsuperuser

按提示输入用户名、邮箱、密码(密码要8位以上且不能过于简单)。之后访问/admin/,你就能用后台管理文章、分类和标签。Admin后台不只是方便,更是调试的好帮手:想快速造数据、看模型字段、修改用户状态,后台点一点就完成,比命令行shell省事得多。

实操体会:博客系统的“门槛”其实是从登录功能开始的。做这一步,你会接触POST请求、CSRF防御、会话、装饰器、内置认证视图,这些是Web开发的核心安全点。把它完全理解了,比多写100篇CRUD代码都值钱。

6. 新手最容易踩的坑:CSRF、静态文件与迁移问题速查

6.1 CSRF攻击原理与防护:为什么忘记加csrf_token就报403

新手第一次用Django写表单,几乎都会遇到这个问题:POST请求提交时报403,页面提示“CSRF verification failed”。很多人第一反应是“我代码是不是写错了”,其实真正的原因是Django默认开启了CSRF防护,而你忘了告诉它这个请求是安全的。

CSRF(跨站请求伪造)的通俗解释:你在A网站登录了账号,会话还在有效期内,然后你打开了B网站的恶意网页,B网站向A网站偷偷发了一个转账POST请求,浏览器带着你的登录Cookie就发出去了,服务器以为是你的合法操作。CSRF防护的核心机制就是:服务器给每个合法表单一个唯一的令牌,提交时若令牌不匹配,就认为请求不合法并拒绝。

Django对CSRF令牌的校验可以类比小区取快递,取件码就是csrf_token,门卫就是服务器,只有取件码对上号的快递才放行。所以在Django里,每个使用POST提交的表格都必须包含{% csrf_token %},DTL渲染它时会在表单里生成类似下面这样的隐藏字段:

<input type="hidden" name="csrfmiddlewaretoken" value="随机字符">

然后Django把value与用户会话中存的令牌比对,一致才放行。别试图绕过它,也别因为嫌麻烦关掉CSRF中间件。生产环境关闭CSRF防护等于把自己家的门锁卸掉,没有任何一个合格的Web框架会推荐你这么做。

6.2 静态文件与部署配置清单

本地开发时,Django在DEBUG=True的情况下会自动帮你服务静态文件。但当你把DEBUG设为False准备上线时,Django的静态文件服务会停掉,这是所有新手第一次部署时都会遇到的迷之404:页面有HTML没有CSS。

解决办法是先运行:

python manage.py collectstatic

这个命令会把所有app以及STATICFILES_DIRS里的静态文件收集到STATIC_ROOT指定的目录下,然后由Nginx这类Web服务器专门负责托管。在settings里加配置:

STATIC_ROOT = BASE_DIR / 'staticfiles'

部署前还有三个必须处理的配置项。第一,ALLOWED_HOSTS,这个列表指定允许访问你的域名。开发时设置成:

ALLOWED_HOSTS = ['localhost', '127.0.0.1']

上线时要加上你的真实域名。如果留空或漏配,访问时Django会直接报DisallowedHost,这是安全机制,防止有人拿别的域名指向你的服务器。

第二,SECRET_KEY不要写在代码仓库里。这个key是Django用于加密会话、签名令牌的核心密钥,泄露出去的后果是攻击者可以伪造会话和签名。正确的做法是通过环境变量注入:

import os SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-only-insecure-key')

开发环境用默认值,生产环境在服务器上设置真实的环境变量。

第三,数据库配置。小流量个人博客用SQLite完全扛得住,但如果你预期有较大并发,建议部署时切换到PostgreSQL。配置方法就是在settings里把DATABASES字典换成PostgreSQL的参数,ORM的代码一行都不用动。

6.3 常见疑难杂症排查速查表

做这个项目过程中我总结了最常出现的几类问题,给你做个速查表,遇到问题对照着查:

症状根本原因解决办法
POST表单提交报403 CSRF模板里忘了{% csrf_token %}在form标签内加上csrf_token
页面有HTML但没有CSS样式DEBUG=False后Django不再服务静态文件运行collectstatic,由Nginx托管
修改模型后数据库没变化运行makemigrations/migrate两步都要执行,缺一不可
删除分类后文章全没了外键on_delete用了CASCADE改用PROTECT或SET_NULL,或软删除
文章时间显示比本地晚8小时USE_TZ=True但TIME_ZONE没设置设置TIME_ZONE='Asia/Shanghai'
未登录访问写文章页,登录后没跳回原页登录模板没保留next参数确认form里包含next字段,默认POST会带上
点击生成的网址变了但页面没反应runserver没重启重启开发服务器,Django不会热更新某些配置
migrate报“表已存在”错误删除了迁移文件但数据库没回滚不要删迁移文件,用migrate app zero回滚
分类页URL访问404路由里没注册category路径检查blog/urls.py里是否有path('category/<int:category_id>/')

最后一个常见问题值得单独说:模板里判断用户权限时,注意user.is_authenticated是属性不是方法,不要写成user.is_authenticated()。这是从旧版Django沿用下来的历史包袱,新版虽然兼容,但新手照抄旧博客代码时经常在这里翻车。

排查问题的方法论也很重要。遇到报错别慌,先看终端里的完整报错堆栈,Django会把出错的代码行、最近的请求信息、具体异常原因全部打印出来。理解一下是哪一层出的问题:是路由层(URL匹配不上)、视图层(Python代码报错)、还是模板层(模板语法错误)。80%的新手问题都能靠读报错信息解决。剩下的,再配合python manage.py check做系统检查,或是在视图里加一行print()看变量的值,定位很快。

关于日志,我再多一句嘴:生产环境项目最好配置日志文件,不然出问题只能对着控制台干瞪眼。简单办法是在settings里加:

LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'handlers': { 'file': { 'level': 'WARNING', 'class': 'logging.FileHandler', 'filename': 'logs/django.log', }, }, 'root': { 'handlers': ['file'], 'level': 'WARNING', }, }

这在入门阶段可能用不上,但提前有这个意识,等你要上线的时候就知道它的价值了。

最后再分享一个我个人的习惯:我做这类练手项目,一定会写一个seed脚本来造测试数据。手动在admin页面一篇篇点文章太痛苦了,写一个命令几分钟就能填充几十条数据,方便调试分页、分类、标签这些功能。比如可以在blog/management/commands/seed.py里用Django的管理命令框架写一套造数据的逻辑,运行python manage.py seed就能一键生成。这个习惯我保留至今,不管项目大小,造测试数据的速度直接影响调试效率。

这个博客系统做完,你手上就有了一个完整的项目骨架。后面想加什么功能,评论、搜索、RSS、文章置顶,都是往已有框架里填东西而已。关键不是功能多华丽,而是你真正理解了请求怎么进来、数据怎么流转、权限怎么控制、安全怎么保障。把这套逻辑吃透,Django全栈开发这条路,你就站上起跑线了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询