做了几年Python后端,接过的需求里有一半以上都带“先弄个网站”这个前提。想快速把想法变成能跑的系统,我第一反应还是Django。这篇就用“构建一个博客系统”这个经典项目,把Django全栈开发的完整路径走一遍:从环境搭建、数据模型设计,到视图路由、模板渲染,再到用户认证、评论互动、分页搜索,最后落地点部署前的安全检查和常见故障排查。整个过程基于Django 5.x版本,强调实操和背后的逻辑,适合刚学完Python基础、想做一个完整项目的同学,也适合后端转全栈的开发者快速建立技术地图。
1. 为什么用Django做博客:选型逻辑与现实考量
1.1 Django的“全家桶”理念到底香在哪
第一次在真实项目里体会到Django的“省心”,是有一次凌晨还在折腾Flask和SQLAlchemy手动搭配登录、表单、分页的中间件组合。你当然可以把Flask搭成任何形状,但每次都要自己去拼积木,时间成本非常高。Django则是“官方帮你配好了一整套”:ORM、Admin后台、表单框架、认证系统、模板语言、中间件机制,全部内置。做一个博客系统,Django从“登录注册”到“文章发布”,几乎每块能力都是现成的,你只需要去控制“我要怎么组织它”。
用Django做博客还有一个隐藏优势:它的Admin后台简直是为内容管理而生的。你不需要前端做一堆管理页面,直接给编辑开一个/admin入口,就能在上面写文章、传封面、管理评论。这在项目很早期、前端还没就绪的时候,能直接把内容生产跑起来,特别适合个人博客、团队知识库这类小团队系统。
1.2 全栈开发在这个项目里指什么
这里说的“全栈”不是要求你前端三件套+数据库+运维全精通,而是借助Django的MVT架构,把“数据模型→业务逻辑→页面渲染”这条完整链路打通。对比传统的前后端分离架构(Vue/React + RESTful API),Django全栈开发的核心优势是:一个Python项目里,你能同时控制数据库表结构、HTTP处理逻辑和HTML渲染结果。
做这种整体式应用时,你不用考虑跨域、Token鉴权、接口文档同步这些前后端分离带来的额外负担,浏览器直接请求Django渲染好的页面,数据直接嵌进HTML返回。对个人项目、企业内部系统、运营后台这种场景,这种方式开发效率最高,维护成本也低。这个博客系统就是一个标准范例:用户看到的文章列表页、详情页、评论表单,都由Django直接渲染,链路短、排查容易。
2. 从零搭建工程:环境、项目骨架与应用拆分
2.1 版本选择与虚拟环境隔离
当前Django主分支已进入5.x,新项目建议直接用5.x版本。Django 5.x对Python 3.10+支持良好,并且移除了大量旧版兼容层,代码更干净。实际操作中,我的选择是:
mkdir django_blog && cd django_blog python -m venv venv source venv/bin/activate # Windows下执行 venv\Scripts\activate pip install django django-admin --version虚拟环境是必须做的,不要图省事直接装在全局环境里。我踩过最痛的一次坑:A项目的Django版本还是2.2,B项目要用5.0,如果装在同一个环境里,依赖冲突能让人疯掉。虚拟环境隔离能让每个项目拥有自己独立的第三方包目录,后续部署到服务器也干净。
Django项目安装完成后,立刻创建项目骨架:
django-admin startproject blog_project python manage.py startapp blog这里的名字值得说明:blog_project是存放全局配置(settings.py、urls.py)的“项目容器”,blog是真正承载业务模型的“应用”。一个项目中可以有多个应用,比如以后想加“相册”模块,可以再建一个gallery应用,这样各模块边界清晰,代码不容易纠缠成一团。
2.2 项目和应用该拆多细
很多新手纠结“要不要现在就建一堆app”,我的建议是:开始阶段只建一个blog应用就够了,等业务真正成长之后再做拆分。过度设计是个人项目最常见的浪费——你花了半天把所有模块分得干干净净,结果半年后才发现当初预想的用户体系根本没有独立价值。
不过有一条底线要守住:业务逻辑必须留在应用里,而不是堆在views.py里变成一坨无法阅读的代码。后续可以按功能拆成services.py、utils.py,或者直接把查询逻辑封装到模型管理器,这都属于代码组织层面的优化,不是架构层面的拆分。
创建好应用后,还需要把blog注册到INSTALLED_APPS,否则Django不会识别应用内的模型和模板。这一步是新手最容易忘记的。
3. 数据模型设计:博客的灵魂在数据库结构
3.1 核心模型:用户、分类、文章、评论
博客系统最核心的四个实体:用户、分类、文章、评论。Django内置了User模型,所以用户这块不用从零写。但有一个点需要认真设计:你的用户表到底够不够用?如果只是登录、发帖,内置User就足够;如果以后要存手机号、生日、头像等额外信息,那就需要定制。
代码层面有两种做法:一种是用AbstractUser继承后扩展字段:
from django.contrib.auth.models import AbstractUser class User(AbstractUser): nickname = models.CharField(max_length=50, blank=True) avatar = models.ImageField(upload_to='avatar/', blank=True)另一种是在内置User基础上新建一对一关系的Profile表。哪个更合理?看项目规模。博客这种轻量场景,直接继承AbstractUser更省事,查用户信息一次就能拿全;Profile方式适合用户体系非常复杂的场景,因为可以按需加载。需要提前设置AUTH_USER_MODEL = 'blog.User',注意一定要在第一次migrate之前设置,否则之后改会被Django警告甚至报错。
分类和文章的代码模型:
class Category(models.Model): name = models.CharField(max_length=100) slug = models.SlugField(unique=True) class Meta: verbose_name_plural = 'categories' def __str__(self): return self.name class Post(models.Model): title = models.CharField(max_length=200) slug = models.SlugField(unique=True) author = models.ForeignKey('User', on_delete=models.CASCADE, related_name='posts') category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='posts') tags = models.ManyToManyField('Tag', blank=True, related_name='posts') content = models.TextField() created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) status = models.CharField(max_length=10, choices=[('draft', '草稿'), ('published', '发布')], default='draft')设计这里面每一个字段时,我都在想“未来读代码的人会不会困惑”。on_delete参数就是最容易困惑的地方。写评论和文章的关系时,CASCADE表示评论跟着作者一起删;但分类被删时,SET_NULL则希望文章保留,只是分类置空。这两种策略代表了两种业务决策,一定要想清楚再写。
评论模型相对简单:
class Comment(models.Model): post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments') author = models.ForeignKey('User', on_delete=models.CASCADE) content = models.TextField() created_at = models.DateTimeField(auto_now_add=True)这里用related_name='comments',后面在模板里就能用post.comments.all()拿到该文章下的所有评论,这个语法糖能省掉手写一堆查询的麻烦。
3.2 外键、多对多与迁移中的细节
模型设计里最值得展开的是“文章和标签”的多对多关系。一篇文章可以打“Python”“Web开发”多个标签,一个标签也能挂在多篇文章上。Django用ManyToManyField自动生成中间表,你不需要手动建关联表,这是它方便的地方。但要注意,如果你需要在关联关系上存储额外信息(比如“A标签是这篇文章的主标签”),就要手动建中间模型,指定through='PostTag',否则默认中间表没有扩展余地。
模型字段写完后,真正数据库变更靠迁移完成:
python manage.py makemigrations python manage.py migrate第一次看到migrate执行日志时,应该留意它到底建了哪些表。Django默认会创建认证相关的表(auth_user等)以及session、admin等内置模块的表。这些表是一个可用系统的基础设施,不要为“太多无关表”而惊讶。
迁移机制背后的逻辑是版本控制数据库结构。你写的每个models.py改动都被记录成一次迁移文件,以后团队协作时,伙伴拉到代码只要执行migrate就能把本地数据库升级到最新,不用手动在数据库里敲SQL。这个设计在真实团队里帮过大忙,但前提是不要去手动删迁移文件。我见过有人为了“清理”直接删掉整个迁移目录,结果数据库状态和版本对不上,最后只能重建,痛不欲生。
4. Admin后台与文章发布:零成本获得管理界面
4.1 注册模型与定制列表
Django的Admin让我对“框架红利”有了直观感知。写完模型后,注册到Admin几乎不需要额外开发:
# blog/admin.py from django.contrib import admin from .models import Post, Category, Comment, Tag @admin.register(Post) class PostAdmin(admin.ModelAdmin): list_display = ('title', 'author', 'category', 'status', 'created_at') list_filter = ('status', 'category', 'tags') search_fields = ('title', 'content') prepopulated_fields = {'slug': ('title',)} date_hierarchy = 'created_at'这段配置每一行都值得解释一下。list_display决定后台列表页展示哪些列,你可以在这里放模型字段,也可以放自定义方法;list_filter是在侧边栏生成按状态、分类、标签的筛选器;search_fields通过title和content两个字段提供搜索;prepopulated_fields则是在管理界面输入标题后自动生成slug,这个细节能避免手动敲URL地址的麻烦。
Admin自定义的可能性非常多,比如用list_editable直接在列表页改状态、用inline在文章编辑页内联操作评论。但有一个原则:Admin只给内部人员用,不要拿它做面向用户的复杂界面。它的价值在于让你用一个下午搭建一套内容管理工具,后续把精力留给面向用户的部分。
4.2 超级用户与后台发布的完整流程
启动后台前必须先创建超级用户:
python manage.py createsuperuser创建过程会提示输入用户名、邮箱、密码,密码必须符合强度要求。之后启动开发服务器:
python manage.py runserver访问http://127.0.0.1:8000/admin/,用刚创建的超级用户登录,就能看到后台管理界面。此时你可以正式发布第一篇文章:在Post里点击“增加”,填写标题、正文、作者,选分类和标签,设置状态为“发布”,保存。
后台操作背后其实完成了三步工作:向数据库插入文章记录、设置作者外键指向当前用户、把多对多标签关系写入中间表。作为一个全栈开发者,看后台不只是点按钮,要能想象它对应的数据变化。这也是为什么我强烈建议新手在Admin走一遍全部流程,因为它能帮你直观理解模型字段和实际数据之间的对应关系。
提示:Admin后台的样式依赖Django自带的静态文件。开发环境一切正常,一旦部署到生产环境设置
DEBUG=False后,后台CSS可能全部丢失,需要在项目里配置好静态文件收集逻辑,这个问题第九章会专门讲。
5. 视图与URL路由:把MVT串成一条链路
5.1 FBV还是CBV
视图是Django处理请求、返回响应的核心枢纽。写视图有两种风格:函数视图(FBV)和类视图(CBV)。函数视图直观,一个函数接一个请求,适合复杂、个性强、无法套通用模板的页面;类视图优雅,Django内置了ListView、DetailView等通用视图,适合列表页、详情页这种高度模板化的场景。
对一个博客系统,我建议两者混用理解。文章列表页用ListView可以省掉大量样板代码:
# blog/views.py from django.views.generic import ListView, DetailView from .models import Post class PostListView(ListView): model = Post template_name = 'blog/post_list.html' context_object_name = 'posts' paginate_by = 10 def get_queryset(self): return Post.objects.filter(status='published').select_related('author', 'category')这里get_queryset中做的filter是把草稿过滤掉,select_related则是一条优化技巧,后面性能部分会细讲。类视图的好处在于,你关注的是“数据怎么查”和“模板叫什么”,而不是写一整套HTTP状态码、模板上下文处理逻辑。
但类视图也有个缺点:当你需要处理特别复杂的逻辑时,类视图的重写链条会让人头晕。此时FBV反而是更清晰的选择。我的判断标准是:如果页面只是标准列表/详情,用CBV;如果页面有复杂表单、多阶段流程,用FBV或者直接写一个专门的Service层。
5.2 路由设计、命名空间与参数传递
在blog/urls.py里配置应用级路由,然后在项目主路由中include进来:
# blog/urls.py from django.urls import path from . import views app_name = 'blog' urlpatterns = [ path('', views.PostListView.as_view(), name='post_list'), path('post/<int:pk>/', views.post_detail, name='post_detail'), path('category/<slug:slug>/', views.category_detail, name='category_detail'), ]# blog_project/urls.py from django.contrib import admin from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('', include('blog.urls')), ]路由里最需要掉入注意的是动态参数的匹配规则。<int:pk>只匹配整数,<slug:slug>匹配文本slug,这种强类型约束能避免把非法请求分发给视图。比如你输入/post/abc/,Django会直接返回404,而不是进入视图发现参数类型不对然后报错。
app_name = 'blog'定义命名空间,是为了在多个应用同时存在时避免路由名冲突。模板里用{% url 'blog:post_detail' pk=post.pk %}生成链接,这样即使后面改了URL前缀,模板代码也不用改,全栈开发的维护性大幅提升。
6. 模板与页面渲染:让数据在浏览器里“长”出来
6.1 模板继承与页面骨架
Django模板系统最重要的思想是“继承”。一个博客统一风格后,所有页面应该有相同的头部导航、底部版权、侧边栏,不需要每个页面各写一份HTML。传统做法是把重复代码复制粘贴,后面改一处要改十个文件。模板继承则是先定义一个base.html作为骨架:
<!-- templates/base.html --> <!DOCTYPE html> <html lang="zh-cn"> <head> <meta charset="UTF-8"> <title>{% block title %}我的博客{% endblock %}</title> <link rel="stylesheet" href="..."> </head> <body> <nav> <a href="{% url 'blog:post_list' %}">首页</a> <a href="{% url 'blog:category_list' %}">分类</a> {% if user.is_authenticated %} <span>{{ user.username }}</span> <a href="{% url 'logout' %}">退出</a> {% else %} <a href="{% url 'login' %}">登录</a> <a href="{% url 'register' %}">注册</a> {% endif %} </nav> <main> {% block content %} {% endblock %} </main> </body> </html>子模板只需要重写自己关心的块:
<!-- templates/blog/post_list.html --> {% extends "base.html" %} {% block title %}最新文章{% endblock %} {% block content %} <ul> {% for post in posts %} <li> <a href="{% url 'blog:post_detail' pk=post.pk %}">{{ post.title }}</a> <span>{{ post.created_at|date:"Y-m-d" }}</span> <span>{{ post.category.name }}</span> </li> {% empty %} <li>暂无文章</li> {% endfor %} </ul> {% endblock %}{% extends %}是核心语法,子模板会自动继承父模板所有结构,然后用block覆盖对应内容块。理解模板继承后,你就可以把导航、页脚、统计代码统一管理起来,以后想加一个备案号,只需要改一处。
6.2 上下文字典、过滤器与模板标签
视图返回给模板的数据统一封装在“上下文”里。对于FBV,上下文就是一个字典:{'posts': queryset};对于CBV,Django会自动帮你组装这个字典,你通过context_object_name控制上下文中的键名。模板中通过{{ posts }}读取。
过滤器是模板格式化数据的利器。我常用的几个:
| 过滤器示例 | 作用 | 典型场景 |
|---|---|---|
{{ post.created_at|date:"Y-m-d" }} | 日期格式化 | 文章发布时间显示 |
{{ post.content|truncatechars:200 }} | 截断文本 | 列表页摘要 |
{{ post.content|linebreaks }} | 把换行转成段落 | 评论区展示富文本 |
注意管道符|两边不能有奇怪的字符,语法非常严格。过滤器和模板标签的区别一定要搞清楚:过滤器是处理变量输出的,标签({% if %}、{% for %}、{% url %})是编程逻辑控制。这是一个很容易搞混的界限,也是面试常客。
模板还有一个隐蔽但重要的特性:自动转义。插入到HTML中的变量默认会被转义,比如用户提交的内容里包含<script>tag</script>,Django会把它转成不执行的字符,这是防止XSS攻击的基本保障。所以除非你明确知道自己在干什么,否则不要使用|safe过滤器,那是关闭转义。真实项目里,XSS漏洞有不少都是因为开发者图省事滥用safe造成的。
7. 用户注册登录与评论互动:从单向展示到双向交互
7.1 用户认证:注册、登录、退出
Django内置了完整的认证系统,包含login、logout、authenticate函数。登录视图的逻辑是:接收表单数据、校验用户名密码、登录用户、跳转。一个常见的注册视图如下:
from django.contrib.auth.models import User from django.contrib.auth import login, authenticate from django.shortcuts import render, redirect def register(request): if request.method == 'POST': username = request.POST['username'] password = request.POST['password'] if User.objects.filter(username=username).exists(): return render(request, 'registration/register.html', {'error': '用户名已存在'}) user = User.objects.create_user(username=username, password=password) login(request, user) return redirect('blog:post_list') return render(request, 'registration/register.html')create_user和create的区别是新手必考点:前者会把密码进行哈希加密再存入数据库,后者则直接存明文。你如果在项目里用了User.objects.create(username=..., password=...),那你的用户表就是一座随时会被攻破的危楼。永远不要直接写明文密码,这是不可触碰的红线。
Django默认的密码哈希算法是PBKDF2,还支持Argon2、bcrypt等更现代的内存硬性算法,安全强度足够,不需要自己造轮子。真正要在意的是:不要在业务代码里手写密码加密逻辑,框架已经给了最优解。
登录视图直接用内置的LoginView能省事不少,重定向到/accounts/profile/的行为可以通过LOGIN_REDIRECT_URL配置去修改:
LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'blog:post_list' LOGOUT_REDIRECT_URL = 'blog:post_list'7.2 评论表单与CSRF防护
评论功能是博客互动的第一步,也是安全风险高发点。Django处理POST请求时必须通过CSRF中间件验证,否则会返回403。模板里的表单一定要加{% csrf_token %}:
<form method="post" action="{% url 'blog:post_detail' pk=post.pk %}"> {% csrf_token %} {{ form.as_p }} <button type="submit">提交评论</button> </form>这个{% csrf_token %}的作用是在表单里生成一个隐藏的CSRF token,服务端提交时会比对请求中的token和session中存的值,以此确认请求来自本站页面而不是第三方伪造站点。不要图省事加@csrf_exempt装饰器,那是关掉一道合法的保险。
评论表单可以定义成一个Django Form:
from django import forms class CommentForm(forms.Form): content = forms.CharField(widget=forms.Textarea, label='评论内容') def post_detail(request, pk): post = get_object_or_404(Post, pk=pk, status='published') if request.method == 'POST': form = CommentForm(request.POST) if form.is_valid(): Comment.objects.create( post=post, author=request.user, content=form.cleaned_data['content'] ) return redirect('blog:post_detail', pk=post.pk) else: form = CommentForm() comments = post.comments.select_related('author').all() return render(request, 'blog/post_detail.html', { 'post': post, 'comments': comments, 'form': form, })这里强制要求用户先登录才能评论,但是request.user可能是匿名用户,需要处理。最简单的方案是在视图里判断request.user.is_authenticated,未登录则跳转登录页。更好的实践是评论表里允许匿名用户,但在页面上通过验证码来防爬,具体取舍取决于你的产品策略。
8. 分页、搜索与性能优化:让博客真正可用
8.1 分页与简单搜索
一个博客的文章数量会持续增长,不可能一页渲染从头到尾。Django的ListView自带分页功能,模板里渲染翻页控件:
{% if is_paginated %} <div class="pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}">上一页</a> {% endif %} <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span> {% if page_obj.has_next %} <a href="?page={{ page_obj.next_page_number }}">下一页</a> {% endif %} </div> {% endif %}对于FBV,手动写分页逻辑也不复杂:
from django.core.paginator import Paginator def post_list(request): posts = Post.objects.filter(status='published') paginator = Paginator(posts, 10) page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) return render(request, 'blog/post_list.html', {'page_obj': page_obj})get_page比get_page_or_404更宽容,越界时会自动返回最后一页,不会直接报错,这个容错在用户体验层面很关键。
搜索功能也是博客需求里的标配。简单场景下不需要引入Elasticsearch,直接用ORM的contains查询即可:
def search(request): q = request.GET.get('q', '') results = Post.objects.filter(status='published', title__icontains=q) return render(request, 'blog/search_results.html', {'results': results, 'query': q})icontains是大小写不敏感的模糊匹配,底层对应SQL里的LIKE %keyword%。这种方式在几千篇文章的规模下体验尚可,但一旦数据量到几万、几十万,LIKE %keyword%会全表扫描。到这种阶段再考虑引入全文搜索引擎,前期不必过度设计。
8.2 查询优化:select_related与prefetch_related
写博客系统时最容忽略的是ORM查询优化。一个列表页要显示10篇文章的作者名和分类名,初学者最容易这样写:先查文章列表,然后在模板里通过post.author.username访问作者名。
这个写法背后隐藏着一个N+1查询问题。查文章列表本身是1次SQL,但渲染模板时每篇文章都要再查一次作者表,10篇文章就是10次额外SQL,全部加起来11次。如果每页50篇文章,就是51次查询。真实项目里数据库压力就是这么被放大的。
解决方法是在查询时告诉Django“连外层关联一起查出来”:
posts = Post.objects.filter(status='published').select_related('author', 'category')select_related会把关联的单个对象(外键、一对一)用SQL的JOIN一次查出。多对多关系则要用prefetch_related,它是先关联表查出一批数据,再统一做第二次查询:
posts = Post.objects.filter(status='published').prefetch_related('tags')怎么验证是否生效?在开发环境开一个SQL日志中间件,或者用Django Debug Toolbar,一目了然能看见每次请求执行了多少条查询。我第一次发现自己列表页要打40多条SQL时,震惊之余也深刻体会到,全栈开发不只是“页面能显示数据”,更要关心到查询效率这一层。
9. 部署前的安全检查与常见故障排查
9.1 必须做掉的安全项
项目能跑通不等于可以上线。部署前我习惯把下面这几项逐一过一遍,每一行都是踩坑换来的经验。
SECRET_KEY是Django签名用的密钥,它被用来加密session、生成Hash等,绝对不能提交到Git仓库。正确做法是从环境变量读取:
import os SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')DEBUG在生产环境必须设成False,否则报错页面会展示完整的堆栈、环境变量、文件路径,等于把系统安全白送给了攻击者。ALLOWED_HOSTS需要配置允许访问的域名列表:
DEBUG = False ALLOWED_HOSTS = ['www.example.com', 'example.com']数据库账户也别用root高权限,博客系统这种业务,单独建一个只有该库读写权限的账号就够了。密码加密、CSRF、XSS转义这些前面已经提过,这里再强调一次:启用Django默认的安全中间件:
MIDDLEWARE = [ # ... 'django.middleware.security.SecurityMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ]简单说,XSS和CSRF是博客这类用户生成内容系统的两条最大安全红线,Django已经在框架层面给足了保护,你要做的不是绕过它们,而是确保自己一直站在框架的保护伞下。
9.2 高频报错与排查实录
我把做博客系统过程中遇到的高频问题整理成一张速查表,这些都是社区里反复出现的问题。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 管理员后台CSS消失 | DEBUG=False后静态文件服务未配置 | 运行collectstatic收集静态文件,由Nginx/Whitenoise托管 |
| 表单提交提示CSRF验证失败 | 模板缺少{% csrf_token %}或Session过期 | 确认模板已加上标签,清理浏览器Cookie重试 |
NoReverseMatch报错 | 路由名错误或缺少参数 | 检查{% url %}里的路由名是否和urls.py一致,缺少的pk、slug参数是否传入 |
| 中文内容乱码 | 数据库连接编码不是UTF-8 | 检查数据库字符集,设置为utf8mb4 |
| 迁移后模型字段变了但报错 | 迁移文件混乱或有人手动改库 | 生成新的迁移,慎用migrate --fake |
| 页面访问404 | URL匹配失败 | 确认path表达式是否准确,动态参数类型是否匹配 |
登录后重定向到/accounts/profile/ | 默认登录重定向地址未配置 | 在settings.py设置LOGIN_REDIRECT_URL |
还有一个我踩过值得写出来的坑:时区问题。Django默认时区是UTC,如果不在settings.py里配置就会导致auto_now_add=True的创建时间比北京时间少8小时,文章明明下午发布,页面上却显示凌晨。正确配置:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = TrueUSE_TZ = True表示以UTC标准时间存入数据库,展示时再转换成TIME_ZONE。如果你改了时区后发现既有数据的时间不对,需要确认旧数据是否按UTC存储,再决定是否批量转换。
结尾
这个博客项目做下来,我最深的感受是:Django的全栈能力并不在于帮你把代码写完,而在于它把开发中真正繁琐的部分——数据库迁移、认证、后台、安全中间件——都收敛成了规范化的解决方案,让你能专注于业务本身。真正碰出经验值的地方,恰恰是那些框架没有替你决定的决策点:模型外键关系怎么定、查询怎么做优化、安全边界画在哪里、错误日志如何排查。把这些地方亲手踩一遍,比看十遍文档都管用。
最后送上一个做完整项目的小建议:不要急着追求把所有功能“一步到位”,先跑通“发文章、看列表、看详情、登录后才能评论”这条最小闭环,后续再逐步补充标签、搜索、分页、部署。每完成一个环节,就提交一次代码并写下当时的决策原因,等整个项目结束往回翻,你会看到自己从一个只会写CRUD的初学者,慢慢变成能对系统整体负责的全栈开发者。这个过程,正是做博客系统最珍贵的回报。