做 Django 项目最容易踩的坑,往往是被一堆教程带着从django-admin startproject开始,学完能写个博客,但真到“从零搭一个能上线的业务系统”就卡住。这门 Udemy 课程《Python & Django: Build & Deploy A Ticketing App In 14 Days》的切入点比较务实:它不带你做增删改查的玩具项目,而是直接做一个工单/票务系统(Ticketing App),并且把部署环节算进 14 天里。从标题能读出的关键信息有三点:技术栈是 Python + Django,项目形态是一个带完整业务流程的 Web 应用,交付目标是“能部署上线”而不是“本地跑通就行”。这三点决定了它和纯语法课、纯前端课的区别。
工单系统这个选题其实很聪明。它天然包含多角色(提交者、处理人、管理员)、状态流转(待处理 → 处理中 → 已解决 → 已关闭)、权限隔离、附件上传、评论记录、列表筛选和分页,几乎覆盖了 Django 里最常用的那批知识点:ORM 建模、ForeignKey 关系、Choices 枚举、类视图、表单校验、登录鉴权、Admin 后台、静态文件、数据库迁移。你把这套东西吃透,换到内容管理系统、审批系统、客服系统、内部报障系统上,结构是可以直接平移的。
硬件门槛很低。Django 是纯 Python 后端框架,开发阶段一台普通笔记本就够,8G 内存跑得动,不需要显卡,不需要 CUDA,也不需要特殊驱动。真正需要花心思的是环境隔离、依赖版本、数据库选择和生产部署这几个环节。下面我会按“需求拆解、环境准备、模型设计、主流程实现、权限与后台、部署上线、接口与性能、排错”这条线,把这个项目从零到上线的路径完整走一遍,代码可以直接复制改。
这篇文章适合三类读者:正在学 Django 但只会写 demo 的人、需要快速交付一个内部工单/报障系统的人、以及想把“能写代码”升级成“能交付系统”的后端初学者。课程本身的具体课时安排、讲师信息和价格,建议以课程页面为准,本文重点放在技术路径和落地细节上。
1. 课程定位与核心能力速览
先给结论:这不是一门 Django 语法课,而是一门“项目驱动 + 部署收尾”的实战课。判断一门框架课值不值得投入时间,我一般看四个维度:项目有没有真实业务复杂度、有没有权限设计、有没有讲数据库迁移与数据初始化、有没有讲生产部署。这四点它从标题上至少覆盖了前前后后,剩下的细节需要看课程目录确认。
| 能力项 | 说明 |
|---|---|
| 技术栈 | Python + Django,模板渲染为主,可能涉及少量 JavaScript |
| 项目类型 | 工单 / 票务系统(Ticketing App),属于典型的多角色业务系统 |
| 核心模块 | 用户认证、工单 CRUD、状态流转、评论、附件、列表筛选与分页 |
| 预期部署方式 | 从开发服务器切换到生产部署(WSGI 服务器 + 反向代理为常见组合) |
| 学习周期 | 标题给的是 14 天,属于按天拆任务的节奏型课程 |
| 硬件门槛 | 普通笔记本即可,无 GPU 要求,主要占用是内存与磁盘 |
| 数据库 | 开发阶段常用 SQLite,生产环境建议切换 PostgreSQL |
| 是否含 API | 课程详情未明确,Django 侧可用 DRF 扩展,本文给出通用做法 |
| 是否含批量任务 | 未明确,工单系统天然有批量导入/批量流转需求,本文补充实现思路 |
| 适合人群 | Django 初学者、后端转栈者、需要交付内部系统的开发者 |
| 不适合人群 | 想深入异步框架、微服务治理、高并发架构的读者 |
有一点要提前说清楚:14 天这个周期,指的是“跟着做能完成”的节奏,不代表学完就具备独立设计系统的能力。通常这类课程的第一周在打地基(环境、模型、视图、模板),第二周在补业务(权限、筛选、评论、附件)和收尾(部署、调试、优化)。如果你每天能保证 2 到 3 小时的有效编码时间,跟上节奏问题不大;如果只是看视频不动手,14 天看完也写不出来。
另外提醒一点:不要指望一门课解决所有工程问题。Django 的学习曲线在前三天最陡,尤其是 ORM 查询和 URL 反向解析这两块,很多人卡在这里就放弃了。建议在开始前先花半小时把 MTV 分层搞清楚,后面会顺很多。
2. 需求拆解:工单系统到底有哪些模块
很多教程失败的地方在于,上来就写代码,写到一半发现模型设计错了,返工成本极高。所以第一步是把需求拆清楚。一个最小可用的工单系统,至少要有下面这几个模块。
2.1 角色与权限边界
工单系统至少要区分三类角色:
- 提交者(普通用户):能创建工单、上传附件、查看自己提交的工单、在工单下追加评论。
- 处理人(客服/工程师):能查看分配给自己的工单、修改状态、回复评论。
- 管理员(staff/superuser):能查看全部工单、分配处理人、维护分类、导出数据。
权限边界必须在视图层做,不能只靠前端隐藏按钮。Django 的LoginRequiredMixin能解决“未登录不能访问”,但“已登录用户不能看别人的工单”必须靠get_queryset()过滤,这一点后面会给出具体写法。
2.2 工单状态流转
状态设计得越简单越好。别一上来就搞十几个状态,最后自己都绕不出来。推荐四态:
| 状态 | 含义 | 谁可以改 |
|---|---|---|
| open | 新建,待分配或待处理 | 管理员、处理人 |
| in_progress | 已接单,处理中 | 处理人 |
| resolved | 已给出解决方案,等待确认 | 处理人 |
| closed | 已关闭,不再变更 | 管理员、提交者确认后 |
状态流转要有约束,比如不能从closed直接跳回open,否则历史记录就乱了。工程上更稳妥的做法是加一张状态变更日志表,记录谁在什么时间把状态从 A 改成 B。这一点课程未必覆盖,但对后续审计很有用。
2.3 数据展示与检索
真实场景里工单会越堆越多,列表页必须支持:
- 按状态筛选;
- 按优先级排序;
- 按标题/描述关键词搜索;
- 分页,每页 20 条左右。
这四条对应的就是 Django 里的filter()、order_by()、icontains、Paginator。写起来不难,但要注意查询效率,列表页如果每条工单都去查一次提交人信息,就是典型的 N+1 问题,后面性能那节会讲怎么用select_related解决。
2.4 适用场景与使用边界
这套系统适合内部报障、IT 支持、售后工单、审批留痕这类场景。不适合直接拿去做面向公网的高并发客服平台,因为还缺限流、审计、消息队列、附件病毒扫描、敏感信息脱敏这些东西。
另外必须强调合规:工单里经常会出现用户手机号、邮箱、故障截图这类敏感信息。部署到生产环境前,至少要做到数据库访问受限、附件目录不可公开枚举、日志里不打印完整个人信息。如果涉及真实用户数据,还需要确认所在业务场景下的数据留存和授权要求。这是技术之外必须提前想清楚的事。
3. 环境准备与 Django 项目初始化
3.1 版本与依赖检查
Django 的版本兼容问题主要出在 Python 版本上。稳妥做法是先确认 Python 版本,再选对应 Django 大版本,具体对应关系以官方文档为准,不要凭记忆装。
python --version pip --version # 建议使用独立的虚拟环境,避免污染全局包 python -m venv .venv # Linux / macOS source .venv/bin/activate # Windows PowerShell # .\.venv\Scripts\Activate.ps1虚拟环境激活后,命令行提示符前面会出现(.venv),这是判断是否激活成功最直接的办法。如果没出现,后面装的包全会落到全局环境,换项目时很容易冲突。
3.2 安装依赖并创建项目骨架
pip install --upgrade pip pip install django pip install python-decouple # 用于读取 .env 配置 pip install pillow # 如果工单涉及图片附件 # 生成依赖清单,方便后续复现环境 pip freeze > requirements.txt # 创建项目与应用 django-admin startproject config . python manage.py startapp tickets这里把项目命名为config,是为了区分“项目配置”和“业务应用”,目录结构会更清爽。执行完目录大概是这样:
. ├── config/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── tickets/ │ ├── models.py │ ├── views.py │ └── urls.py ├── manage.py └── requirements.txt记得在settings.py的INSTALLED_APPS里注册tickets,否则迁移时不会创建对应的表。
3.3 配置拆分与环境变量
开发和生产配置一定要分开。最省事的办法是用环境变量 +.env文件,密钥不进代码仓库。
# config/settings.py 片段 import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-insecure-key") DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost").split(",") INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "tickets", ] # 登录相关跳转 LOGIN_URL = "login" LOGIN_REDIRECT_URL = "ticket_list" LOGOUT_REDIRECT_URL = "login".env文件不要提交到版本库,务必加进.gitignore。这一步很多人偷懒,结果密钥跟着代码一起被推到公开仓库,后面再补救就麻烦了。
4. 数据模型设计:工单、评论、附件与状态
模型是整个项目的骨架,改起来最贵,所以先设计。核心三张表:分类、工单、评论,外加一张附件表。
# tickets/models.py from django.conf import settings from django.db import models from django.urls import reverse class Category(models.Model): name = models.CharField(max_length=64, unique=True) class Meta: verbose_name_plural = "categories" ordering = ["name"] def __str__(self): return self.name class Ticket(models.Model): class Status(models.TextChoices): OPEN = "open", "待处理" IN_PROGRESS = "in_progress", "处理中" RESOLVED = "resolved", "已解决" CLOSED = "closed", "已关闭" class Priority(models.TextChoices): LOW = "low", "低" NORMAL = "normal", "普通" HIGH = "high", "高" URGENT = "urgent", "紧急" title = models.CharField("标题", max_length=200) description = models.TextField("问题描述") category = models.ForeignKey( Category, on_delete=models.SET_NULL, null=True, blank=True, related_name="tickets" ) status = models.CharField( "状态", max_length=20, choices=Status.choices, default=Status.OPEN, db_index=True ) priority = models.CharField( "优先级", max_length=20, choices=Priority.choices, default=Priority.NORMAL ) submitter = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="submitted_tickets" ) assignee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets" ) created_at = models.DateTimeField("创建时间", auto_now_add=True, db_index=True) updated_at = models.DateTimeField("更新时间", auto_now=True) class Meta: ordering = ["-created_at"] indexes = [models.Index(fields=["status", "priority"])] def __str__(self): return f"#{self.pk} {self.title}" def get_absolute_url(self): return reverse("ticket_detail", args=[self.pk]) class Comment(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="comments") author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) body = models.TextField("内容") created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at"] class Attachment(models.Model): ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="attachments") file = models.FileField("附件", upload_to="attachments/%Y/%m/") uploaded_at = models.DateTimeField(auto_now_add=True)几个设计点值得单独说。
第一,settings.AUTH_USER_MODEL而不是直接导入User。这是 Django 的推荐写法,将来换成自定义用户模型时不用改外键。
第二,on_delete的选择。工单删除时评论和附件应该跟着删,用CASCADE;分类被删时工单不该消失,用SET_NULL并把字段设为可空;处理人被删时工单要保留,同样用SET_NULL。这三种语义要分清,选错了会造成数据丢失。
第三,加了复合索引["status", "priority"]。列表页最常见的查询就是“按状态筛 + 按优先级排”,这个索引能直接用上。
第四,upload_to="attachments/%Y/%m/"按年月分目录,文件多了以后不会堆在一个文件夹里。
模型写完执行迁移:
python manage.py makemigrations python manage.py migrate # 创建超级用户,用来登录 Admin 后台 python manage.py createsuperuser迁移文件要跟着代码一起提交。有人在服务器上重新makemigrations,结果生成的迁移和本地不一致,表结构直接对不上,这类事故完全可以避免。
5. 视图、路由与模板:把提交到关闭的主流程跑通
主流程有四步:列表 → 详情 → 新建 → 修改状态。用 Django 的类视图写,代码量比函数视图少,也更容易复用。
# tickets/views.py from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import ListView, DetailView, CreateView, UpdateView from .forms import TicketForm from .models import Ticket class TicketListView(LoginRequiredMixin, ListView): model = Ticket template_name = "tickets/ticket_list.html" context_object_name = "tickets" paginate_by = 20 def get_queryset(self): qs = Ticket.objects.select_related("submitter", "assignee", "category") # 普通用户只能看到自己提交的工单 if not self.request.user.is_staff: qs = qs.filter(submitter=self.request.user) status = self.request.GET.get("status") if status in dict(Ticket.Status.choices): qs = qs.filter(status=status) keyword = self.request.GET.get("q") if keyword: qs = qs.filter(title__icontains=keyword) return qs class TicketDetailView(LoginRequiredMixin, DetailView): model = Ticket template_name = "tickets/ticket_detail.html" def get_queryset(self): qs = super().get_queryset().select_related("submitter", "assignee") if not self.request.user.is_staff: qs = qs.filter(submitter=self.request.user) return qs class TicketCreateView(LoginRequiredMixin, CreateView): model = Ticket form_class = TicketForm template_name = "tickets/ticket_form.html" success_url = reverse_lazy("ticket_list") def form_valid(self, form): form.instance.submitter = self.request.user return super().form_valid(form) class TicketUpdateView(LoginRequiredMixin, UpdateView): model = Ticket form_class = TicketForm template_name = "tickets/ticket_form.html" def get_queryset(self): # 只有 staff 能改工单,普通用户提交后不可编辑 return Ticket.objects.filter() if self.request.user.is_staff else Ticket.objects.none()路由配置:
# tickets/urls.py from django.urls import path from . import views urlpatterns = [ path("", views.TicketListView.as_view(), name="ticket_list"), path("new/", views.TicketCreateView.as_view(), name="ticket_create"), path("<int:pk>/", views.TicketDetailView.as_view(), name="ticket_detail"), path("<int:pk>/edit/", views.TicketUpdateView.as_view(), name="ticket_edit"), ]# config/urls.py from django.contrib import admin from django.contrib.auth import views as auth_views from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("login/", auth_views.LoginView.as_view(template_name="registration/login.html"), name="login"), path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("", include("tickets.urls")), ]表单里要注意字段白名单,别把submitter、status这种应该由服务端控制的字段暴露给前端:
# tickets/forms.py from django import forms from .models import Ticket class TicketForm(forms.ModelForm): class Meta: model = Ticket fields = ["title", "description", "category", "priority"] widgets = { "description": forms.Textarea(attrs={"rows": 6, "placeholder": "请描述问题现象、出现时间和影响范围"}), } def clean_title(self): title = self.cleaned_data["title"].strip() if len(title) < 5: raise forms.ValidationError("标题至少 5 个字符,方便后续检索") return title模板层不用写太花,一个列表页加一个详情页就能验证流程。列表页记得渲染分页控件,详情页把评论列表和附件列表带出来。Django 模板自动转义,正常情况下不用担心 XSS;但如果用了|safe过滤器渲染富文本,就必须先做内容清洗,这是很多项目出问题的地方。
6. 权限控制、表单校验与 Admin 后台
6.1 三类越权要分别防
越权通常有三种形态,要一个一个堵。
水平越权:A 用户改 URL 里的工单 ID,看到 B 用户的工单。防法是在get_queryset()里按submitter过滤,上面代码已经做了。
垂直越权:普通用户直接访问/admin/或修改状态接口。防法是用UserPassesTestMixin或@staff_member_required,并且把敏感操作放在 POST 请求里,不要用 GET 改数据。
参数篡改:表单里偷偷提交status=closed。防法就是上面说的字段白名单,ModelForm只允许指定字段,其余字段一律服务端赋值。
6.2 Admin 后台是免费的运维面板
Django Admin 是这门框架最被低估的部分。工单系统上线初期,运营人员不可能等你去开发一个管理界面,直接把 Admin 配好就能用。
# tickets/admin.py from django.contrib import admin from .models import Attachment, Category, Comment, Ticket class CommentInline(admin.TabularInline): model = Comment extra = 0 @admin.register(Ticket) class TicketAdmin(admin.ModelAdmin): list_display = ("id", "title", "status", "priority", "submitter", "assignee", "created_at") list_filter = ("status", "priority", "category") search_fields = ("title", "description") date_hierarchy = "created_at" autocomplete_fields = ("submitter", "assignee") inlines = [CommentInline] @admin.action(description="批量标记为已解决") def mark_resolved(self, request, queryset): queryset.update(status=Ticket.Status.RESOLVED) actions = ["mark_resolved"] @admin.register(Category) class CategoryAdmin(admin.ModelAdmin): search_fields = ("name",) admin.site.register(Attachment)search_fields和autocomplete_fields是配套的,外键字段要用自动补全,被引用的 Admin 必须定义search_fields,否则会报错。这个坑第一次配 Admin 的人基本都会踩。
6.3 附件上传的安全边界
FileField本身不做类型校验。生产环境至少要限制扩展名和大小,并且把上传目录放到 Web 服务器不可直接执行的路径下。
# tickets/forms.py 片段 ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".pdf", ".txt", ".log"} MAX_SIZE = 5 * 1024 * 1024 # 5MB def clean_file(self): f = self.cleaned_data["file"] suffix = "." + f.name.rsplit(".", 1)[-1].lower() if suffix not in ALLOWED_EXT: raise forms.ValidationError("不支持的文件类型") if f.size > MAX_SIZE: raise forms.ValidationError("文件不能超过 5MB") return f如果工单内容会对外开放,还需要考虑敏感信息脱敏和附件访问鉴权。默认情况下MEDIA_URL配好后文件是可以通过 URL 直接访问的,这在内部系统里通常可以接受,但涉及个人信息时就要改成后端流式返回并校验权限。
7. 部署上线:从 runserver 到生产环境
runserver只能本地调试,单线程、无静态文件服务、无进程守护,绝对不能直接对外。上线要做的动作有固定顺序,漏一步就白屏或 500。
7.1 上线前必改的三项配置
# config/settings.py 生产环境片段 DEBUG = False ALLOWED_HOSTS = ["your-domain.com", "www.your-domain.com"] CSRF_TRUSTED_ORIGINS = ["https://your-domain.com"] STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR / "staticfiles" # collectstatic 输出目录 MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" # 生产建议 PostgreSQL,驱动用 psycopg DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("DB_NAME", "tickets"), "USER": os.environ.get("DB_USER", "tickets"), "PASSWORD": os.environ.get("DB_PASSWORD", ""), "HOST": os.environ.get("DB_HOST", "127.0.0.1"), "PORT": os.environ.get("DB_PORT", "5432"), } }DEBUG=False之后,Django 不再托管静态文件,必须执行collectstatic并让 Nginx 接管,否则页面会变成纯文本、样式全丢。这是部署后最常见的第一个问题。
7.2 WSGI 服务启动
# 收集静态文件 python manage.py collectstatic --noinput # 执行迁移(每次发布都跑一次) python manage.py migrate --noinput # 用 gunicorn 启动,worker 数量按 CPU 核数调整 gunicorn config.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 3 \ --timeout 60 \ --access-logfile - \ --error-logfile -worker 数量不是越多越好。经验公式是2 × CPU 核数 + 1,2 核机器给 3 到 5 个就够。给太多反而会因为内存竞争拖慢响应,尤其是机器内存只有 2G 的时候。
7.3 Nginx 反向代理配置
server { listen 80; server_name your-domain.com; client_max_body_size 10M; location /static/ { alias /srv/ticketing/staticfiles/; expires 7d; } location /media/ { alias /srv/ticketing/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_set_header X-Forwarded-Proto $scheme; } }client_max_body_size一定要配,默认 1M,附件稍微大一点就 413。X-Forwarded-Proto也要带,不然 Django 判断不出请求是 HTTPS,重定向和 CSRF 校验都可能出问题。
7.4 进程守护与日志
生产环境别用nohup挂着跑,进程挂了没人拉起。用 systemd 更稳:
# /etc/systemd/system/ticketing.service [Unit] Description=Ticketing Django App After=network.target [Service] User=www-data WorkingDirectory=/srv/ticketing EnvironmentFile=/srv/ticketing/.env ExecStart=/srv/ticketing/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target改完之后systemctl daemon-reload再systemctl restart ticketing。日志建议用journalctl -u ticketing -f实时看,比在项目里翻文件快。
8. 接口化、批量任务与性能观察
8.1 用 DRF 快速加一层 API
工单系统早晚要对接其他平台,比如监控告警自动开工单、企业 IM 机器人推送。加一层 REST 接口成本很低。
pip install djangorestframework# tickets/serializers.py from rest_framework import serializers from .models import Ticket class TicketSerializer(serializers.ModelSerializer): submitter = serializers.StringRelatedField(read_only=True) class Meta: model = Ticket fields = ["id", "title", "description", "status", "priority", "submitter", "created_at"] read_only_fields = ["id", "status", "created_at"]# tickets/api.py from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated from .models import Ticket from .serializers import TicketSerializer class TicketViewSet(viewsets.ModelViewSet): serializer_class = TicketSerializer permission_classes = [IsAuthenticated] def get_queryset(self): qs = Ticket.objects.select_related("submitter") if not self.request.user.is_staff: qs = qs.filter(submitter=self.request.user) return qs def perform_create(self, serializer): serializer.save(submitter=self.request.user)路由用DefaultRouter注册即可。注意ModelViewSet默认开放了DELETE,如果不希望工单被删,就不要继承完整的 ViewSet,或者重写destroy改成软删除。
8.2 接口调用示例
import requests BASE = "http://127.0.0.1:8000/api/tickets/" TOKEN = "<your-token>" payload = { "title": "生产环境 502 告警", "description": "10:32 开始出现 502,影响桌面端登录,初步怀疑后端进程退出", "priority": "urgent", } resp = requests.post( BASE, json=payload, headers={"Authorization": f"Bearer {TOKEN}"}, timeout=15, ) resp.raise_for_status() print(resp.json())8.3 批量任务怎么做
批量场景主要有两类:批量导入历史工单、批量修改状态。不建议直接在视图里循环写数据库,容易超时且没有失败重试。
# management/commands/import_tickets.py import csv from django.core.management.base import BaseCommand from django.contrib.auth import get_user_model from tickets.models import Ticket User = get_user_model() class Command(BaseCommand): help = "从 CSV 批量导入工单,格式:title,description,priority" def add_arguments(self, parser): parser.add_argument("csv_path") parser.add_argument("--submitter", required=True) def handle(self, *args, **options): submitter = User.objects.get(username=options["submitter"]) objs, failed = [], [] with open(options["csv_path"], newline="", encoding="utf-8") as f: for idx, row in enumerate(csv.DictReader(f), start=2): title = (row.get("title") or "").strip() if not title: failed.append((idx, "标题为空")) continue objs.append(Ticket( title=title, description=row.get("description", ""), priority=row.get("priority", "normal"), submitter=submitter, )) Ticket.objects.bulk_create(objs, batch_size=500) self.stdout.write(f"成功导入 {len(objs)} 条,跳过 {len(failed)} 条") for line, reason in failed[:20]: self.stderr.write(f"第 {line} 行:{reason}")用bulk_create而不是逐条save(),几千条数据的导入时间能从分钟级降到秒级。代价是它不触发save()信号,也不自动填auto_now_add之外的业务逻辑,用之前要确认没有依赖信号的地方。
8.4 性能观察看哪几个点
没有具体测试数据的情况下,我给一套通用的观察方法,具体数值要你本机测。
- 看 SQL 条数:列表页如果用
select_related,20 条工单应该只产生 1 到 2 条查询。可以在settings.py里临时配LOGGING把django.db.backends的 SQL 打出来,或者用django-debug-toolbar。 - 看响应时间:
runserver的日志会打印每条请求耗时,超过 300ms 的接口就要查原因。 - 看索引命中:状态筛选和创建时间排序是高频查询,
EXPLAIN一下确认走了索引。 - 看内存:gunicorn worker 长时间运行内存持续上涨,通常是全局缓存或大对象没释放,用
--max-requests 1000定期重启 worker 可以缓解。 - 看附件体积:
media目录增长很快,建议按年月归档并定期清理。
降低资源占用的手段很直接:列表页别SELECT *,用.only()限定字段;分页大小从 50 降到 20;关掉 DEBUG;把静态文件交给 Nginx;数据库连接用持久连接(CONN_MAX_AGE)。这些改动比换服务器划算得多。
9. 常见问题、排查方法与最佳实践
9.1 高频问题对照表
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
启动报ModuleNotFoundError | 虚拟环境没激活或依赖没装 | which python、pip list | 重新激活 venv 并pip install -r requirements.txt |
迁移报No changes detected | 应用未注册到INSTALLED_APPS | 检查settings.py | 注册应用后重新makemigrations |
迁移冲突multiple leaf nodes | 多人同时生成迁移文件 | 查看migrations/目录 | 合并迁移或删除冲突文件后重新生成 |
| 页面样式全丢 | DEBUG=False后未collectstatic | 浏览器看/static/是否 404 | 执行collectstatic并检查 Nginxalias |
访问报DisallowedHost | ALLOWED_HOSTS未包含域名 | 看报错里的 Host 值 | 补上域名,含不带 www 的版本 |
| 提交表单 403 | CSRF token 缺失或CSRF_TRUSTED_ORIGINS未配 | 看请求头与表单 | 模板加{% csrf_token %},配置可信来源 |
| 上传附件 413 | Nginx 请求体限制 | 看 Nginx error log | 调大client_max_body_size |
| 用户能看到别人的工单 | 视图未按submitter过滤 | 换账号手动改 URL 测试 | 在get_queryset()中加过滤 |
| 列表页很慢 | N+1 查询 | 打 SQL 日志看条数 | 加select_related/prefetch_related |
| 502 Bad Gateway | gunicorn 未启动或端口不对 | systemctl status、ss -lntp | 重启服务,核对proxy_pass端口 |
| 数据库连接数打满 | worker 数过多或连接未释放 | 看数据库pg_stat_activity | 降低 worker 数,配置CONN_MAX_AGE |
| 批量导入卡住 | 逐条save()且无事务 | 观察日志和数据库锁 | 改bulk_create,分批提交 |
9.2 排查的基本顺序
遇到问题别急着改代码,按这个顺序走一遍,八成能定位:
- 看终端或
journalctl的完整报错,Django 的 traceback 最后几行才是根因; - 确认当前用的是哪个 Python 解释器和哪个虚拟环境;
- 确认数据库迁移是否执行到最新;
- 确认静态文件和媒体文件路径是否可读;
- 用
curl -I直接打后端端口,区分是 Nginx 问题还是应用问题。
“页面打不开”和“页面打开了但报 500”是两类完全不同的故障,前者查网络和进程,后者查代码和数据库。这个判断能省掉一半时间。
9.3 工程化建议
先跑最小闭环,再加功能。第一天不要写权限、不要写附件、不要写 API,就做“登录 → 新建工单 → 列表能看到 → 详情能打开”这四步。跑通之后再往上叠,出问题时才好定位是哪一层引入的。
配置和代码分离。密钥、数据库密码、域名全部走环境变量,.env进.gitignore。本地和生产用同一份代码、不同的环境变量,避免“本地好的线上挂”。
目录分层要清晰。输入素材、上传附件、导出报表、日志文件分开放,不要全塞项目根目录。备份的时候按目录备份,恢复的时候才不会漏。
批量任务必须带日志和重试。每条记录处理完打一行日志,失败的单独落到一个失败文件里,跑完统计成功/失败数量。没有日志的批量任务一旦出错,你连错在哪一条都不知道。
接口服务限制访问范围。API 上线后至少要有认证,能加 IP 白名单就加,能限流就限流。工单接口如果暴露在公网且无鉴权,等于把内部问题清单公开了。
合规和授权要前置。工单里出现的人名、手机号、截图、日志,都属于可能涉及个人信息的内容。上线前确认数据留存周期、访问权限范围、导出审批流程。涉及第三方素材或用户上传内容的,必须确认授权链条完整。这些不是技术问题,但一旦出问题,技术方案再漂亮也没用。
发布前做一次回归清单。登录、新建、列表筛选、详情、评论、附件上传、状态修改、后台登录,八个动作走一遍,五分钟能覆盖 90% 的低级错误。
回到这门课本身,它最值得跟的地方是把“部署”放进了主线。大多数 Django 入门内容停在runserver,学员一到真实环境就抓瞎。如果你能跟着把工单系统从模型设计做到 Nginx 反代,中间那些collectstatic、ALLOWED_HOSTS、CSRF_TRUSTED_ORIGINS的坑都亲手踩一遍,这套经验换到任何 Django 项目上都能复用。
真要开始动手,建议第一件事不是看视频,而是先把数据模型画出来,四个状态、三个角色、两张关联表,画清楚再写代码。最容易翻车的地方不是语法,是权限过滤漏了一处,或者DEBUG=False之后忘了收集静态文件。把这两点记住,剩下的都是体力活。