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 的 MTVModel-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_length50, uniqueTrue) sort_order models.IntegerField(排序, default0) class Meta: ordering [sort_order, id] def __str__(self): return self.name class Scenic(models.Model): 景点主表基础信息 地理坐标 开放状态 name models.CharField(景点名称, max_length120, db_indexTrue) category models.ForeignKey(Category, on_deletemodels.PROTECT, related_namescenics) description models.TextField(详细介绍) cover_image models.ImageField(封面图, upload_toscenic/%Y/%m/) price models.DecimalField(门票价格, max_digits8, decimal_places2) province models.CharField(省份, max_length30, db_indexTrue) city models.CharField(城市, max_length30) is_open models.BooleanField(是否开放, defaultTrue) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: ordering [-created_at] def __str__(self): return self.name class TourLine(models.Model): 旅游线路一条线路关联多个景点按天排列 title models.CharField(线路标题, max_length150, db_indexTrue) scenic_spots models.ManyToManyField(Scenic, throughLineItem, related_namelines) days models.PositiveIntegerField(行程天数, default1) price models.DecimalField(线路价格, max_digits10, decimal_places2) stock models.PositiveIntegerField(剩余名额, default0) is_active models.BooleanField(是否上架, defaultTrue) class LineItem(models.Model): 中间表记录线路包含哪些景点按第几天排序 line models.ForeignKey(TourLine, on_deletemodels.CASCADE) scenic models.ForeignKey(Scenic, on_deletemodels.CASCADE) day models.PositiveIntegerField(第几天) sort models.PositiveIntegerField(当日顺序, default0) class Meta: ordering [day, sort]这段代码里有三个细节值得展开。第一Category 到 Scenic 用on_deletemodels.PROTECT意思是分类下还有景点时不允许删除分类避免出现孤儿数据而 LineItem 到 TourLine 用CASCADE线路删掉后中间记录自动清理这才符合业务预期。第二price字段用 DecimalField 而不是 FloatField钱这种东西用浮点存会出 0.10.20.30000000000000004 的经典问题最终订单金额对不上账。第三auto_now_addTrue让创建时间只写一次auto_nowTrue则每次保存都刷新两者别混。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 django4.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_length20, uniqueTrue, nullTrue, blankTrue) avatar models.ImageField(头像, upload_toavatar/%Y/%m/, nullTrue, blankTrue) points models.IntegerField(积分, default0) 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_length20, label手机号) class Meta: model User fields (username, phone, password1, password2) def clean_phone(self): phone self.cleaned_data.get(phone) if User.objects.filter(phonephone).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_openTrue) province self.request.GET.get(province, ) sort self.request.GET.get(sort, default) if province: qs qs.filter(provinceprovince) 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_openTrue).values_list(province, flatTrue).distinct() ctx[current_province] self.request.GET.get(province, ) ctx[current_sort] self.request.GET.get(sort, default) return ctx两个关键点select_related(category)解决的是外键查询的 N1 问题。如果模板里对每条景点都访问scenic.category.nameDjango 默认每次访问都触发一次 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 classcard h3{{ scenic.name }}/h3 p{{ scenic.province }} / {{ scenic.city }}/p p价格¥{{ scenic.price }}/p /div {% endfor %} div classpagination {% 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 %} /div3.3 订单状态机从提交到支付确认的字段设计订单表是旅游网站最核心的业务表它的状态字段设计直接决定后续所有功能的复杂度。你不能只用一个status 0/1/2然后靠注释记住 0 是什么时间一长谁都记不住。class Order(models.Model): STATUS_CHOICES [ (unpaid, 待支付), (paid, 已支付), (cancelled, 已取消), (refunded, 已退款), (completed, 已完成), ] user models.ForeignKey(User, on_deletemodels.PROTECT, related_nameorders) line models.ForeignKey(TourLine, on_deletemodels.PROTECT, related_nameorders) order_no models.CharField(订单号, max_length32, uniqueTrue, editableFalse) quantity models.PositiveIntegerField(报名人数, default1) amount models.DecimalField(订单金额, max_digits10, decimal_places2) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultunpaid, db_indexTrue) created_at models.DateTimeField(auto_now_addTrue) paid_at models.DateTimeField(支付时间, nullTrue, blankTrue) 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_deleteCASCADE景点删了评论跟着删评分字段用PositiveSmallIntegerField并加校验允许 1-5 分。class Review(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_namereviews) scenic models.ForeignKey(Scenic, on_deletemodels.CASCADE, related_namereviews) rating models.PositiveSmallIntegerField(评分, default5) content models.TextField(评论内容, max_length1000) created_at models.DateTimeField(auto_now_addTrue) class Meta: ordering [-created_at] constraints [ models.UniqueConstraint(fields[user, scenic], nameunique_user_scenic_review), models.CheckConstraint(checkmodels.Q(rating__gte1, rating__lte5), namerating_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_openTrue) 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__icontainskeyword) | Q(description__icontainskeyword) ) if province: queryset queryset.filter(provinceprovince) if category_id: queryset queryset.filter(category_idcategory_id) if min_price: queryset queryset.filter(price__gtemin_price) if max_price: queryset queryset.filter(price__ltemax_price) return queryset.select_related(category)逻辑说明Q对象用来表达或关系keyword 同时匹配名称和描述price__gte和price__lte是大于等于、小于等于的范围查询min_price和max_price用字符串接收后直接传给 ORM 是可以的Django 会做类型转换如果传入非法字符ORM 层会抛ValidationError生产环境建议在前面加一层try/except或做正则校验这是防御性编程的底线。这种写法的坑在于category_id如果传了一个不存在的 idfilter不会报错只是返回空集合体验上表现为页面空白但没提示。我一般会在视图里先Category.objects.filter(idcategory_id).exists()判断一下不存在则忽略该筛选条件更符合用户预期。4.2 全文检索方案对比数据库 LIKE 不行谁来顶上景点介绍、线路详情都是长文本用icontains做关键词搜索时数据库执行的是LIKE %keyword%这种查询不走索引数据量到几万条时明显变慢。旅游网站的搜索词通常是瀑布古镇亲子这类短词对分词要求不高。我个人最推荐的方案是 Django 内置的PostgreSQL full-text search但国内很多项目用的 MySQL为此引入 Elasticsearch 又太重。退一步的做法是把搜索高频字段单独存一份搜索索引文本应用层去重直接建索引字段class Scenic(models.Model): # ... 原有字段 search_text models.TextField(搜索冗余, blankTrue, editableFalse) 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()), namescenic_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 DEBUGFalse 后静态文件 404CSS/JS 全挂现象本地开发时页面样式正常python manage.py runserver一切完好一旦用DEBUGFalse启动或部署到服务器访问页面变成纯 HTML 没有样式控制台一片 404。原因Django 开发服务器会在 DEBUGTrue 时自动帮你托管静态文件但 DEBUGFalse 后它不再处理静态文件路由这是 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_TZTrue时Django 存数据库的时间统一是 UTC模板渲染时会按TIME_ZONE转换成当地时间如果TIME_ZONE留的是默认UTC显示出来就比北京时间少 8 小时。解决把TIME_ZONE改成Asia/ShanghaiUSE_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_rootsettings.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-CSRFTokenDjango 默认不校验普通 Header很多人在这里卡住。视图层面如果确实要给 API 类接口免除 CSRF 校验可以用csrf_exempt但不建议用在含登录功能的表单视图上。6. 部署与验证从开发服务器到 Nginx uWSGI 的最后一公里6.1 生产环境部署的最小配置本地开发跑runserver一切正常这不叫完成上线才是真正的开始。我常用的部署组合是 Nginx uWSGI MySQL RedisuWSGI 和 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 LargeDjango 日志里什么都看不到因为请求根本没到应用层。改完 Nginx 配置记得nginx -t systemctl reload nginx。6.2 上线后的验证清单与一个压箱底的调试技巧部署完成后不要急着宣布上线成功按下面顺序过一遍能拦下至少 70% 的线上事故注册一个真实用户提交一次订单确认订单状态流转正确、支付时间显示为北京时间。访问景点详情页确认图片能打开评论能提交评分不能超 5 分。用curl -I https://yourdomain.com/scenic/1/查看响应头确认也没有 500、502 和 Content-Type 错误。在 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 生产项目后最想留给初手的一份自查笔记。本文还有配套的精品资源点击获取