ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Django旅游网站开发实战:从MTV架构到订单系统与生产部署

Django旅游网站开发实战:从MTV架构到订单系统与生产部署

简介:基于Python与Django框架的旅游自主网站项目,面向Web开发入门者和需要完成课程设计的进阶学习者,适合作为毕业设计、课程设计、大作业、工程实训或初期项目立项的参考。网站围绕旅游业务组织功能,包含景点展示、路线规划、餐饮推荐、用户互动等常见模块,前端采用响应式页面并配合Vue组件与地图元素,后端遵循Django模型-视图-模板的开发模式,整体结构清楚,便于阅读理解与实际运行。压缩包共2001个文件,包括1600余张旅游实景图片、前端样式脚本、80余个Python后端源码、Vue组件、地图数据、SQLite数据库及项目配置文件,整体约132.64MB,目录分类明确,可快速定位代码、素材和数据。目前已有144人学习。借助这套资源,使用者可以获得完整项目源码、可运行的数据库初始数据、页面素材与配置说明,在此基础上既可以用作阶段性学习练手,也能够二次开发或继续扩展业务功能,有效缩短个人项目从搭建到演示的周期。

1. 旅游自主网站:不只是一个 CRUD,而是一套完整的在线旅游业务模型

一个看起来"无非是发布景点、展示线路"的 Django 项目,真正动手后你会发现:用户注册登录、景点分页、线路筛选、下单支付、评论打分、后台维护,每一块都在考验你对框架的理解深度。做旅游自主网站的人和做普通博客的人,差距往往不是会不会写视图函数,而是能不能把数据库设计、请求生命周期和部署链路串成一个整体。这套项目非常适合作为 Python 后端的第一份完整实战,它能让你同时练到 Model 设计、Form 校验、Class-based View、中间件和部署脚本,而不是停留在"跟着教程敲一遍"的水平。本文我会按做一个可用产品的路径拆开讲:从为什么选 Django,到数据建模、订单流程、搜索性能、常见翻车点,最后落到生产部署和验证方法,全程给可复现代码和参数说明。

2. 为什么选 Django 做旅游网站:MTV 架构与 ORM 选型理由

2.1 Django 的 MTV 架构怎么对应旅游网站的功能模块

旅游网站和普通内容站的本质区别在于它有完整的业务闭环:游客浏览内容、注册用户下单、后台管理员维护数据。如果用 Flask 自己拼,路由、ORM、表单校验、后台管理都要分别选型,项目做到一半容易陷入"黑匣子"式的调试。Django 的 MTV(Model-Template-View)模式恰好把这三层固化下来,Model 对应数据库表,Template 对应前端页面渲染,View 对应业务逻辑。换个角度看,这样的分层让团队协作也更清晰:一个人管模型设计,另一个人管模板,不会互相踩脚。

在我做过的旅游项目里,MVT 的映射通常是这样落的:

  • Model 层:用户表、景点表、线路表、酒店表、订单表、评论表。字段类型、外键关系、索引设计都在这里定死。
  • View 层:负责处理请求,查数据库,把数据传给模板。列表页用 ListView,详情页用 DetailView,下单流程用 FormView,登录注册用 Django 自带的 auth 视图或自定义视图。
  • Template 层:只做渲染,不在模板里写业务逻辑。{% for %} 循环、{% if %} 判断足够,但不要在模板里调 ORM 查数据库。

选 Django 还有一个被很多人低估的理由:自带 Admin 后台。旅游网站最耗时间的部分不是用户看到的前台页面,而是运营人员维护景点介绍、上架线路、下架酒店的操作界面。Django Admin 注册好模型后,后台管理功能零代码就有,省下的时间足够你去做搜索优化和部署配置。

2.2 数据建模:从景点、线路到订单的 ER 设计与代码实现

如果你一开始就把所有字段塞进一张表,后期加需求就是灾难。我见过有人把景点、线路、酒店全放一张 product 表,用 type 字段区分,结果做订单关联和价格核算时不断写 if type == 'scenic' 这样的分支,查询效率低且维护成本高。正确做法是按业务实体拆表,用外键和中间表表达关系。

核心是这几张表:

# tourism/models.py (Django 3.2+ 推荐写法) from django.db import models from django.contrib.auth.models import User class Category(models.Model): """景点/线路的分类,比如自然风光、人文历史""" name = models.CharField('分类名', max_length=50, unique=True) sort_order = models.IntegerField('排序', default=0) class Meta: ordering = ['sort_order', 'id'] def __str__(self): return self.name class Scenic(models.Model): """景点主表:基础信息 + 地理坐标 + 开放状态""" name = models.CharField('景点名称', max_length=120, db_index=True) category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name='scenics') description = models.TextField('详细介绍') cover_image = models.ImageField('封面图', upload_to='scenic/%Y/%m/') price = models.DecimalField('门票价格', max_digits=8, decimal_places=2) province = models.CharField('省份', max_length=30, db_index=True) city = models.CharField('城市', max_length=30) is_open = models.BooleanField('是否开放', default=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: ordering = ['-created_at'] def __str__(self): return self.name class TourLine(models.Model): """旅游线路:一条线路关联多个景点,按天排列""" title = models.CharField('线路标题', max_length=150, db_index=True) scenic_spots = models.ManyToManyField(Scenic, through='LineItem', related_name='lines') days = models.PositiveIntegerField('行程天数', default=1) price = models.DecimalField('线路价格', max_digits=10, decimal_places=2) stock = models.PositiveIntegerField('剩余名额', default=0) is_active = models.BooleanField('是否上架', default=True) class LineItem(models.Model): """中间表:记录线路包含哪些景点,按第几天排序""" line = models.ForeignKey(TourLine, on_delete=models.CASCADE) scenic = models.ForeignKey(Scenic, on_delete=models.CASCADE) day = models.PositiveIntegerField('第几天') sort = models.PositiveIntegerField('当日顺序', default=0) class Meta: ordering = ['day', 'sort']

这段代码里有三个细节值得展开。第一,Category 到 Scenic 用on_delete=models.PROTECT,意思是分类下还有景点时不允许删除分类,避免出现"孤儿数据";而 LineItem 到 TourLine 用CASCADE,线路删掉后中间记录自动清理,这才符合业务预期。第二,price字段用 DecimalField 而不是 FloatField,钱这种东西用浮点存会出 0.1+0.2=0.30000000000000004 的经典问题,最终订单金额对不上账。第三,auto_now_add=True让创建时间只写一次,auto_now=True则每次保存都刷新,两者别混。

2.3 django 创建 app 与项目初始化:最小命令集

很多新手在这一步就开始翻车:项目建好后不知道先做什么。我的习惯顺序是:先建虚拟环境,再装依赖,然后一次性把项目骨架和 app 结构拉起来。

# 1. 创建虚拟环境并激活(Python 3.10+) python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 安装 Django 和数据库驱动 pip install django==4.2.* mysqlclient # 或 psycopg2-binary 如果你用 PostgreSQL # 3. 创建项目与核心 app django-admin startproject tourism_site . python manage.py startapp tourism # 4. 立即迁移内置表 python manage.py migrate # 5. 创建后台管理员 python manage.py createsuperuser

注意几点:startproject tourism_site .后面的点号表示在当前目录生成 manage.py,不要漏掉,否则目录结构会多套一层,后面路径配置容易出问题;migrate在做任何模型修改之前执行,它会先建好 Django 自带的 user、session、admin 等表,之后再makemigrations时你的业务表就能和系统表共存;createsuperuser建议现在就做,后面开发调试时你会频繁用 Admin 后台加测试数据,不要拖到部署阶段才想起来。

tourism这个 app 名是单数,符合 Django 惯例。如果你把 app 命名为api、views这类通用名,后面写from api.models import Scenic看起来没什么,但项目规模一大,多个 app 相互引用时命名冲突会让人头皮发麻。

3. 从注册到下单:用户体系与订单流程的实现细节

3.1 用 Django 自带 User 模型还是自定义用户表

这是一个绕不开的决策点。项目刚开始时用 Django 自带的auth.User最省事,它已经实现了密码哈希、会话管理、权限系统,你只需要通过request.user就能拿到当前登录用户。但旅游网站通常需要存手机号、昵称、头像、积分这些字段,直接给 User 加字段的方式有两种:一是新建一个 Profile 表用 OneToOne 关联 User,二是项目一开始就自定义 User 模型。

我的建议是:如果这是从零开始的旅游网站,直接用自定义 User 模型。原因不在于你当前需要多少字段,而在于 Django 的AUTH_USER_MODEL配置一旦在迁移过之后更改,会引发连锁的迁移错误,被称为"后悔药都没得吃"。更合理的做法是项目创建第一天就设置:

# settings.py AUTH_USER_MODEL = 'tourism.User' # tourism/models.py from django.contrib.auth.models import AbstractUser class User(AbstractUser): phone = models.CharField('手机号', max_length=20, unique=True, null=True, blank=True) avatar = models.ImageField('头像', upload_to='avatar/%Y/%m/', null=True, blank=True) points = models.IntegerField('积分', default=0) class Meta: db_table = 'tourism_user'

注册视图要同时处理用户名、密码、手机号三个字段,用 Django 的UserCreationForm做基础,再扩展自定义字段:

# tourism/forms.py from django import forms from django.contrib.auth.forms import UserCreationForm from .models import User class RegisterForm(UserCreationForm): phone = forms.CharField(max_length=20, label='手机号') class Meta: model = User fields = ('username', 'phone', 'password1', 'password2') def clean_phone(self): phone = self.cleaned_data.get('phone') if User.objects.filter(phone=phone).exists(): raise forms.ValidationError('该手机号已注册') return phone

这里要注意ok一个细节:UserCreationForm默认使用User模型,但你在Meta里指定model = User后,它只会处理fields里列出的字段,phone的校验逻辑要在clean_phone里写,否则用户填了重复手机号也会创建成功,订单关联用户时会查出多条记录,这是我在项目里真的踩过的坑。

3.2 景点与线路的分页列表:ListView 的 queryset 覆盖

列表页是访问压力最大的页面。Django 的ListView自带分页,但默认只按模型顺序输出,你需要覆盖get_queryset和get_context_data来满足旅游网站的实际需求——比如只展示开放的景点、按省份筛选、排序方式可切换。

# tourism/views.py from django.views.generic import ListView from .models import Scenic class ScenicListView(ListView): model = Scenic template_name = 'tourism/scenic_list.html' paginate_by = 12 context_object_name = 'scenics' def get_queryset(self): qs = super().get_queryset().filter(is_open=True) province = self.request.GET.get('province', '') sort = self.request.GET.get('sort', 'default') if province: qs = qs.filter(province=province) if sort == 'price_asc': qs = qs.order_by('price') elif sort == 'price_desc': qs = qs.order_by('-price') elif sort == 'newest': qs = qs.order_by('-created_at') return qs.select_related('category') def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['province_list'] = Scenic.objects.filter(is_open=True).values_list('province', flat=True).distinct() ctx['current_province'] = self.request.GET.get('province', '') ctx['current_sort'] = self.request.GET.get('sort', 'default') return ctx

两个关键点:select_related('category')解决的是外键查询的 N+1 问题。如果模板里对每条景点都访问scenic.category.name,Django 默认每次访问都触发一次 SQL 查询,12 条景点就是 13 条 SQL;加上select_related后一次 JOIN 搞定,页面响应时间能差出好几倍。province_list用values_list(...).distinct()拿到省份列表,这是构建筛选菜单的数据源,注意这里不要写Scenic.objects.all(),因为带 is_open 条件更合理。

模板侧的分页控件需要你把当前请求参数拼进链接,否则用户在第一页选了"广东",翻到第二页筛选条件就丢了:

<!-- templates/tourism/scenic_list.html 片段 --> {% for scenic in scenics %} <div class="card"> <h3>{{ scenic.name }}</h3> <p>{{ scenic.province }} / {{ scenic.city }}</p> <p>价格:¥{{ scenic.price }}</p> </div> {% endfor %} <div class="pagination"> {% if page_obj.has_previous %} <a href="?page={{ page_obj.previous_page_number }}&province={{ current_province }}&sort={{ current_sort }}">上一页</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 }}&province={{ current_province }}&sort={{ current_sort }}">下一页</a> {% endif %} </div>

3.3 订单状态机:从提交到支付确认的字段设计

订单表是旅游网站最核心的业务表,它的状态字段设计直接决定后续所有功能的复杂度。你不能只用一个status = 0/1/2然后靠注释记住 0 是什么,时间一长谁都记不住。

class Order(models.Model): STATUS_CHOICES = [ ('unpaid', '待支付'), ('paid', '已支付'), ('cancelled', '已取消'), ('refunded', '已退款'), ('completed', '已完成'), ] user = models.ForeignKey(User, on_delete=models.PROTECT, related_name='orders') line = models.ForeignKey(TourLine, on_delete=models.PROTECT, related_name='orders') order_no = models.CharField('订单号', max_length=32, unique=True, editable=False) quantity = models.PositiveIntegerField('报名人数', default=1) amount = models.DecimalField('订单金额', max_digits=10, decimal_places=2) status = models.CharField('状态', max_length=20, choices=STATUS_CHOICES, default='unpaid', db_index=True) created_at = models.DateTimeField(auto_now_add=True) paid_at = models.DateTimeField('支付时间', null=True, blank=True) def save(self, *args, **kwargs): if not self.order_no: self.order_no = f"{self.user_id}{int(self.created_at.timestamp())}" super().save(*args, **kwargs) def mark_paid(self): from django.utils import timezone self.status = 'paid' self.paid_at = timezone.now() self.save(update_fields=['status', 'paid_at'])

状态用字符串而不是数字,可读性好很多,Django Admin 下拉框里能直接显示中文,运营人员不会看到 0/1/2 猜含义。mark_paid方法把状态变更和支付时间绑定在一起,并且用update_fields只更新两个字段,避免把created_at也刷新。订单号不要用自增 id 暴露给用户,一个简单的拼接字符串(用户 id + 时间戳)就够用;如果量大了怕撞号,再加一个随机段或直接用 uuid4 的 hex 值。

3.4 评论与评分:外键关系和防刷设计

评论表设计得不好会让景点详情页越来越慢,更重要的是会被人刷垃圾数据。模型上注意两点:外键用on_delete=CASCADE(景点删了评论跟着删),评分字段用PositiveSmallIntegerField并加校验,允许 1-5 分。

class Review(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='reviews') scenic = models.ForeignKey(Scenic, on_delete=models.CASCADE, related_name='reviews') rating = models.PositiveSmallIntegerField('评分', default=5) content = models.TextField('评论内容', max_length=1000) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] constraints = [ models.UniqueConstraint(fields=['user', 'scenic'], name='unique_user_scenic_review'), models.CheckConstraint(check=models.Q(rating__gte=1, rating__lte=5), name='rating_range_valid') ]

UniqueConstraint保证一个用户对一个景点只能有一条评论,这是最基础的防刷手段,比在视图里查一遍"是否存在"更可靠——数据库层面的唯一约束在并发请求下也能兜住。CheckConstraint把评分范围锁死在 1 到 5,不能在视图层保障的合法性,在数据库层强制。这两类约束在迁移时会生成对应的 SQL 约束,只要你做了makemigrations就会生效。

4. 搜索、筛选与性能:让静态页面变成可用产品

4.1 用 Django ORM 实现多维筛选组合

旅游网站上最常见的筛选组合是:省份 + 分类 + 价格区间 + 关键词。新手容易用filter(参数存在才加)的字符串拼接方式做,但推荐用 Q 对象和字典拆包来动态构造 queryset,代码更干净。

from django.db.models import Q def scenic_search(request): queryset = Scenic.objects.filter(is_open=True) keyword = request.GET.get('q', '').strip() province = request.GET.get('province', '') category_id = request.GET.get('category', '') min_price = request.GET.get('min_price', '') max_price = request.GET.get('max_price', '') if keyword: queryset = queryset.filter( Q(name__icontains=keyword) | Q(description__icontains=keyword) ) if province: queryset = queryset.filter(province=province) if category_id: queryset = queryset.filter(category_id=category_id) if min_price: queryset = queryset.filter(price__gte=min_price) if max_price: queryset = queryset.filter(price__lte=max_price) return queryset.select_related('category')

逻辑说明:Q对象用来表达"或"关系,keyword 同时匹配名称和描述;price__gte和price__lte是大于等于、小于等于的范围查询,min_price和max_price用字符串接收后直接传给 ORM 是可以的,Django 会做类型转换;如果传入非法字符,ORM 层会抛ValidationError,生产环境建议在前面加一层try/except或做正则校验,这是防御性编程的底线。

这种写法的坑在于:category_id如果传了一个不存在的 id,filter不会报错,只是返回空集合,体验上表现为"页面空白但没提示"。我一般会在视图里先Category.objects.filter(id=category_id).exists()判断一下,不存在则忽略该筛选条件,更符合用户预期。

4.2 全文检索方案对比:数据库 LIKE 不行,谁来顶上

景点介绍、线路详情都是长文本,用icontains做关键词搜索时,数据库执行的是LIKE '%keyword%',这种查询不走索引,数据量到几万条时明显变慢。旅游网站的搜索词通常是"瀑布""古镇""亲子"这类短词,对分词要求不高。

我个人最推荐的方案是 Django 内置的PostgreSQL full-text search,但国内很多项目用的 MySQL,为此引入 Elasticsearch 又太重。退一步的做法是把搜索高频字段单独存一份"搜索索引文本",应用层去重,直接建索引字段:

class Scenic(models.Model): # ... 原有字段 search_text = models.TextField('搜索冗余', blank=True, editable=False) def save(self, *args, **kwargs): self.search_text = f"{self.name} {self.province} {self.city} {self.category.name} {self.description}" super().save(*args, **kwargs)

保存景点时把所有可搜索字段拼成一段文本,查询时只搜这一个字段。配合 MySQL 的全文索引或简单地search_text__icontains,性能比在多个字段上做 LIKE 好很多,因为只有一个字段需要扫描。缺点是冗余数据需要同步,如果你改分类名称的时候没有级联刷新景点的 search_text,搜索会漏数据——解决方式是在分类的 save 方法里self.scenics.update(search_text=...),或者更省心:后台维护完数据后跑一个管理命令重建全部 search_text。

bepinex(il2cpp)框架nuxt框架这类词不直接相关,就不用引入搅浑水。如果你的项目确实要做中文分词,Django 生态里比较常见的搭配是jieba分词后把词列表存 JSONField,但那属于增量需求,第一版用冗余字段足够。

4.3 缓存与数据库查询优化:从缓存整个页面到缓存查询结果

旅游网站的景点详情页访问频率高、数据更新少,是缓存策略的最好试验场。Django 的缓存框架支持 Redis、Memcached、数据库、文件系统,我实际项目里用 Redis 最多,配置一行就够:

# settings.py CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379/1', 'TIMEOUT': 300, } }

缓存整个页面的最快做法是使用 Django 自带的cache_page装饰器:

from django.views.decorators.cache import cache_page urlpatterns = [ path('scenic/<int:pk>/', cache_page(60 * 15)(ScenicDetailView.as_view()), name='scenic_detail'), ]

这行配置让详情页在 15 分钟内不重新查询数据库,同一景点被反复访问时压力几乎为 0。但注意:如果用户在详情页看到"30 分钟前有人下单",该信息也会被缓存 15 分钟。更精细的做法是只缓存查询结果,在视图里用cache.get/cache.set缓存 QuerySet 求值后的列表,还需要考虑缓存失效问题——景点下架、评论新增都要主动删缓存键。我实际经验是详情页用cache_page,列表页的分页结果不用缓存,因为筛选条件组合太多,缓存命中率低,按省份和分类做键值缓存更划算。

5. 旅游网站开发的避坑清单:5 个最容易翻车的位置

5.1 改了 models.py 后 makemigrations 报"字段已存在"或迁移丢失

现象:你在本地开发时删除了一个字段,后来又从备份里恢复了 models.py 代码,再次makemigrations得到的迁移文件提示字段已存在,或者迁移文件应用到线上后出现字段对不上。

原因:Django 的迁移记录在 app 的 migrations 目录和 django_migrations 表里各有一份,你手动删除过迁移文件,或者团队同事改了同一个 app 的模型而你没有同步迁移文件。

解决:小团队项目不要手动删迁移文件。改字段前先python manage.py makemigrations生成迁移,再python manage.py migrate应用。如果已经出现不一致,最保底的方案是把 app 的迁移文件重置:备份数据后,清空相关表的映射,DROP TABLE django_migrations中对应对这个 app 的记录,重新执行makemigrations和migrate。这操作是"后悔药",不建议在线上随便执行,开发阶段没问题;线上的规范做法是按 git 合并迁移文件,绝不让两个人同时改同一个 app 的 models。

5.2 DEBUG=False 后静态文件 404,CSS/JS 全挂

现象:本地开发时页面样式正常,python manage.py runserver一切完好,一旦用DEBUG=False启动或部署到服务器,访问页面变成纯 HTML 没有样式,控制台一片 404。

原因:Django 开发服务器会在 DEBUG=True 时自动帮你托管静态文件,但 DEBUG=False 后它不再处理静态文件路由,这是 Django 设计的安全策略,不是 bug。

解决:生产环境由 Nginx 托管静态文件,先python manage.py collectstatic把各 app 的静态资源收集到STATIC_ROOT指定目录,然后 Nginx 配置:

location /static/ { alias /var/www/tourism/static/; expires 7d; } location /media/ { alias /var/www/tourism/media/; }

如果你是临时测试,用 Django 直接跑生产模式调试,可以用django.contrib.staticfiles.views.serve配合条件判断手动托管,但那只限于内网测试,公网必须走 Nginx。这个坑我见过太多次,新手往往在这里浪费一天,最后发现只是没配STATIC_ROOT。

5.3 时区设置错误导致订单时间差 8 小时

现象:用户 14:00 下的订单,后台显示 06:00,支付时间比实际时间少了 8 个小时,或者反过来多了 8 小时。

原因:settings.py里TIME_ZONE和USE_TZ的配合没弄对。当USE_TZ=True时,Django 存数据库的时间统一是 UTC,模板渲染时会按TIME_ZONE转换成当地时间;如果TIME_ZONE留的是默认'UTC',显示出来就比北京时间少 8 小时。

解决:把TIME_ZONE改成'Asia/Shanghai',USE_TZ保持True。注意datetime.now()和timezone.now()的区别:前者返回本地时间(会引发警告或歧义),后者返回 UTC 时间。凡是要存入 DateTimeField 的,一律用django.utils.timezone.now()。订单记录的paid_at也要用timezone.now(),这样数据库层面依旧统一,模板输出时自动转成北京时间。

5.4 上传的图片能保存但访问 URL 返回 404

现象:Admin 后台成功上传了景点封面,/media/scenic/2024/06/xxx.jpg路径在浏览器直接访问 404,但在服务器上用ls能看到文件明明存在。

原因:Media 文件的 URL 映射没有配置。Django 在开发环境下需要你在根 URLconf 里手动加静态托管路由,生产环境下 Nginx 没把/media/这类前缀反向代理到 MEDIA_ROOT 对应的目录。

解决:开发环境在urls.py加:

from django.conf import settings from django.conf.urls.static import static urlpatterns = [ # ... 你的所有路由 ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

另一个同时出现的坑是MEDIA_ROOT路径不对。很多人只设置了MEDIA_URL = '/media/',忘了MEDIA_ROOT,导致文件被写到当前运行目录下的 media 子目录,跟你预期位置差十万八千里。两个配置要配套写,MEDIA_ROOT必须是绝对路径,我习惯用BASE_DIR / 'media'。

5.5 CSRF 验证失败,表单提交 403

现象:注册页面、下单页面一点提交按钮就报CSRF verification failed,本地开发没问题,部署到服务器后开始出现。

原因:Django 对 POST 请求强制检查 CSRF Token。如果你的模板里的 {% csrf_token %} 没生效,或者模板被浏览器缓存、Nginx 设置了页面缓存导致 Token 不刷新,都会 403。部署环境里最常见的触发点是你用了cache_page缓存了包含表单的页面。

解决:带 POST 的表单页面不能整页缓存,至少不能缓存带 CSRF Token 的部分。要么用cache_page只缓存 GET 请求展示的数据片段,要么表单页一率排除缓存,改用局部缓存。另一个容易被忽略的点是:AJAX POST 请求需要从 Cookie 里读取csrftoken放到请求头X-CSRFToken,Django 默认不校验普通 Header,很多人在这里卡住。视图层面如果确实要给 API 类接口免除 CSRF 校验,可以用@csrf_exempt,但不建议用在含登录功能的表单视图上。

6. 部署与验证:从开发服务器到 Nginx + uWSGI 的最后一公里

6.1 生产环境部署的最小配置

本地开发跑runserver一切正常,这不叫完成,上线才是真正的开始。我常用的部署组合是 Nginx + uWSGI + MySQL + Redis,uWSGI 和 Nginx 的连接用 Unix Socket 文件。先说说 uWSGI 配置文件,很多人直接写在命令行里也能跑,但可维护性太差:

# uwsgi.ini [uwsgi] chdir = /srv/tourism_site module = tourism_site.wsgi:application master = true processes = 2 threads = 2 socket = /srv/tourism_site/uwsgi.sock chmod-socket = 664 vacuum = true die-on-term = true

配置说明:processes和threads的数量我建议根据服务器 CPU 核数定,2 核机器配processes = 2是安全的起点,再加threads = 2让每个进程能处理更多并发。socket用 Unix Socket 比 TCP 端口更快,但 Nginx 的www-data用户必须有权限访问它,所以chmod-socket = 664是关键参数;如果你遇到 502 Bad Gateway,第一反应先去查 socket 文件和 Nginx 运行用户的权限。vacuum会在退出时清理 socket 文件,避免残留。

Nginx 站点配置里,除了静态文件托管,反向代理部分长这样:

server { listen 80; server_name yourdomain.com; client_max_body_size 20M; # 图片上传限制,默认 1M 会报 413 location / { include uwsgi_params; uwsgi_pass unix:///srv/tourism_site/uwsgi.sock; uwsgi_read_timeout 300; } location /static/ { alias /srv/tourism_site/static/; } location /media/ { alias /srv/tourism_site/media/; } }

client_max_body_size这个参数很容易被忽略。运营人员在后台传一张 5MB 的景点图,Nginx 直接返回 413 Request Entity Too Large,Django 日志里什么都看不到,因为请求根本没到应用层。改完 Nginx 配置记得nginx -t && systemctl reload nginx。

6.2 上线后的验证清单与一个压箱底的调试技巧

部署完成后,不要急着宣布"上线成功",按下面顺序过一遍,能拦下至少 70% 的线上事故:

  1. 注册一个真实用户,提交一次订单,确认订单状态流转正确、支付时间显示为北京时间。
  2. 访问景点详情页,确认图片能打开,评论能提交,评分不能超 5 分。
  3. 用curl -I https://yourdomain.com/scenic/1/查看响应头,确认也没有 500、502 和 Content-Type 错误。
  4. 在 Admin 后台随便编辑一条景点,保存后看前台是否更新——如果用了缓存,检查缓存未刷新问题。

最后一个技巧:线上排查问题时不要直接改代码然后重启,先用python manage.py shell进交互环境手动查数据、模拟操作,确认是逻辑问题还是环境问题。我见过太多人在 Nginx 日志和 uWSGI 日志里大海捞针,其实问题出在数据库连接数太多、Redis 缓存键没设过期时间这类很简单的地方。进入 shell 后执行from django.db import connection; print(connection.queries)可以看到当前请求执行的 SQL,配合django-debug-toolbar在本地复现,比盲猜高效得多。

用 uWSGI 跑起来后还有一个习惯我想分享:不要把cat /var/log/uwsgi.log当作唯一的排查手段,因为默认日志级别太碎,真正有用的 traceback 会被淹掉。更稳的做法是在settings.py里配置一个独立的日志文件用来接住未捕获异常:

LOGGING = { 'version': 1, 'handlers': { 'file': { 'class': 'logging.FileHandler', 'filename': '/srv/tourism_site/logs/django.log', } }, 'loggers': { 'django': { 'handlers': ['file'], 'level': 'ERROR', } } }

这个配置会把所有 500 错误堆栈写进单独文件,线上用户反馈"页面打不开"时,第一件事tail -20 /srv/tourism_site/logs/django.log,十次里有九次直接定位到报错行,剩下的才是 Nginx 或操作系统层面的问题。希望这套流程和踩坑清单能帮你在做旅游网站部署时少走一段弯路,也是我做了几个 Django 生产项目后最想留给初手的一份自查笔记。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进