资讯详情

Python+Django 14天工单系统实战:权限、状态流转与部署

📅 2026/10/23 2:12:24 | 华诺云谱 👁 阅读
Python+Django 14天工单系统实战:权限、状态流转与部署
很多 Python 学习者都会卡在同一个位置Django 教程能跟着敲完models、views、urls、templates 都认识但真要做一套能交给别人用的系统就发现缺的并不是语法而是把权限、状态流转、部署、排错这几件事串起来的能力。这也是为什么“用 14 天做完一个工单系统”这类实战项目一直有市场——它的业务复杂度刚好卡在一个很合适的区间不算玩具也不至于一开始就劝退。我的判断是工单Ticketing系统是学 Django 后端最划算的练手题目之一原因不是它简单而是它的每个技术点都有真实对应物。多角色权限对应真实的账号体系工单状态流转对应真实的事务与并发问题附件上传对应真实的存储和安全边界部署上线对应真实的环境变量与静态文件处理。你在教程里学到的零散知识只有被一个完整业务反复使用才会真正连成一条线。这篇文章不从课程推荐的角度写而是把“Python Django 构建并部署一个工单系统”这条路径完整拆开领域模型怎么设计、14 天怎么排节奏、状态机怎么防错、权限怎么收口、代码怎么验证、最后怎么用 gunicorn Nginx 部署上去。文中涉及的 Django 版本请以你实际项目的约束为准示例代码在 Django 4.2 LTS 及之后的版本上都可以参考重点在于设计思路与落地细节而不是某个补丁版本的行为差异。1. 为什么工单系统适合作为 Django 完整项目先说清楚一个常见误区很多人以为练手项目要“功能多”才有价值于是堆上聊天、支付、搜索、推荐最后每块都只做了一半跑起来能看到页面但一按业务流程就断。工单系统反过来它的功能数量其实不多但每条业务链路都要求闭环提交之后必须有人受理受理之后必须能流转流转之后必须有记录关闭之后必须能追溯。这种约束会迫使你把 Django 的各个层次真正用起来。工单系统的另一层价值在于它天然带着“角色”这个概念。普通用户只能看见自己提交的工单客服能看见分配给自己或被指派给自己的管理员能看见全部并可以调整优先级和状态。这个需求看起来简单落到代码上就是查询集过滤、装饰器与混入类、对象级权限校验三个层次任何一层写漏都会造成数据越权。这是绝大多数 Web 教程不会深入、但企业项目必然踩到的地方。还有一个容易被忽略的收益是部署。工单系统需要静态文件、需要数据库迁移、需要后台常驻进程、需要反向代理转发请求头这几件事凑在一起正好覆盖了 Django 从开发环境走上服务器的主要障碍。你在本地跑得再顺如果不做一遍 collectstatic、不配置 Nginx 转发、不处理 ALLOWED_HOSTS上线第一步就会失败。这个话题的实操价值远超单纯的增删改查练习。适合读这篇的读者画像也比较明确已经写过 Django 基础视图、能看懂 Model 和 Form、但没独立完成过完整项目或者做过 Java 或 PHP 后端想快速迁移到 Python 技术栈再或者手上正好要做一个内部工单、报障、需求受理类的工具需要一个可以直接参考的骨架。如果你连 Python 的类继承和装饰器都不熟建议先补上这一层否则后面读源码式的内容会比较吃力。2. 工单系统的领域模型与数据库设计2.1 先定概念再定表结构工单系统里最容易出问题的地方是概念含糊就开始建表。比如“状态”和“阶段”混用、“处理人”和“受理人”混用最后代码里到处是 if 判断。比较稳妥的做法是先把核心名词固定下来一张工单 Ticket 有标题、描述、分类、优先级、状态、提交人、处理人一条回复 Comment 挂在工单下区分“对用户可见”和“仅内部可见”一个附件 Attachment 挂在回复或工单上一条操作日志 TicketLog 记录每次状态变更、指派和优先级调整。概念定清楚之后表结构基本是自然推导出来的。这里有个判断值得说操作日志不要省。很多练手项目只存工单当前状态不存历史结果用户问“这单为什么被关掉、谁关的”你答不出来。真实工单系统几乎都会保留完整的流转轨迹因为它同时承担了审计和责任归属两个用途。在 14 天的节奏里日志表大概只占半天工作量但缺了它后面所有排查都只能靠猜。2.2 核心模型代码下面是最小可用的模型设计放在tickets/models.py。用TextChoices定义状态和优先级好处是既有数据库层的稳定字符串值又能在模板和表单里直接拿到可读标签避免魔法字符串散落各处。# 文件路径tickets/models.py from django.conf import settings from django.db import models class Category(models.Model): name models.CharField(分类名称, max_length50, uniqueTrue) slug models.SlugField(标识, max_length50, uniqueTrue) class Meta: verbose_name 工单分类 verbose_name_plural verbose_name def __str__(self): return self.name class Ticket(models.Model): class Priority(models.TextChoices): LOW low, 低 NORMAL normal, 普通 HIGH high, 高 URGENT urgent, 紧急 class Status(models.TextChoices): OPEN open, 待受理 IN_PROGRESS in_progress, 处理中 PENDING pending, 等待反馈 RESOLVED resolved, 已解决 CLOSED closed, 已关闭 title models.CharField(标题, max_length200) content models.TextField(问题描述) category models.ForeignKey( Category, verbose_name分类, on_deletemodels.PROTECT, related_nametickets, ) priority models.CharField( 优先级, max_length10, choicesPriority.choices, defaultPriority.NORMAL, ) status models.CharField( 状态, max_length20, choicesStatus.choices, defaultStatus.OPEN, db_indexTrue, ) created_by models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name提交人, on_deletemodels.CASCADE, related_namecreated_tickets, ) assignee models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name处理人, on_deletemodels.SET_NULL, nullTrue, blankTrue, related_nameassigned_tickets, ) created_at models.DateTimeField(创建时间, auto_now_addTrue) updated_at models.DateTimeField(更新时间, auto_nowTrue) class Meta: ordering [-created_at] verbose_name 工单 verbose_name_plural verbose_name indexes [ models.Index(fields[status, priority]), models.Index(fields[assignee, status]), ] def __str__(self): return f#{self.pk} {self.title} class Comment(models.Model): ticket models.ForeignKey( Ticket, verbose_name所属工单, on_deletemodels.CASCADE, related_namecomments ) author models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name回复人, on_deletemodels.CASCADE) content models.TextField(回复内容) is_internal models.BooleanField(仅内部可见, defaultFalse) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: ordering [created_at] verbose_name 工单回复 verbose_name_plural verbose_name这段模型里有三个细节值得展开。第一category用了on_deletemodels.PROTECT而不是CASCADE因为分类是字典数据删掉分类不应该连带删掉历史工单用 PROTECT 强迫你先处理关联数据。第二assignee用SET_NULL客服离职时工单要保留但处理人置空后续重新指派即可。第三给status加了索引并且额外建了组合索引因为工单列表页最常见的查询就是“按状态筛选 按优先级排序”。2.3 状态与优先级的边界模型层还要考虑一个现实问题优先级和状态到底谁可以改。比较合理的划分是提交人在工单关闭前可以改优先级但不能改状态状态只能由处理人或管理员变更分类创建后不允许提交人改动否则统计数据会失真。这些规则不要写在视图的 if 里散落一堆而是收敛到一个服务层函数里下一节会给出完整实现。数据库选型上SQLite 足够支撑前 10 天的开发验证但涉及select_for_update这类行锁的代码在 SQLite 上行为和一个真实的 PostgreSQL 并不一致。如果这个项目你打算真的部署给团队用建议在第 5 天左右就切到 PostgreSQL用同样的模型和迁移脚本跑一遍早发现问题比临上线再换库成本低得多。3. 环境准备与项目初始化3.1 基础环境开发环境这里给出一个通用约定Python 3.10 及以上版本Django 4.2 LTS 及以上数据库开发期用 SQLite、测试与生产用 PostgreSQL代码风格统一用blackflake8。具体小版本号请跟随你所在团队的依赖规范本文不做过度绑定因为不同组织对长期支持版本的选择差异很大。虚拟环境是必须的不要在系统 Python 里直接装包后面换机器和换版本都会很痛苦。# 创建并激活虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 下使用 .venv\Scripts\activate # 安装依赖 pip install --upgrade pip pip install -r requirements.txt依赖清单建议在第一天就固定下来放到项目根目录的requirements.txt# 文件路径requirements.txt Django4.2,6.0 psycopg[binary]3.1 gunicorn21.2 python-dotenv1.0 whitenoise6.6这里解释一下每一项的必要性psycopg是 PostgreSQL 驱动psycopg[binary]省去了本地编译依赖gunicorn是生产环境的 WSGI 服务器python-dotenv用来在本地加载.env文件whitenoise让静态文件不必强依赖 Nginx 也能提供。把版本区间写成x,y而不是精确等于是为了在大版本内自动拿到安全补丁同时避免跨大版本的不兼容。3.2 项目初始化命令django-admin startproject config . python manage.py startapp tickets注意startproject config .后面那个点它让 manage.py 直接生成在当前目录而不是多套一层文件夹后面部署路径会清晰很多。应用名用tickets而不是app是因为 Django 的模板查找、静态文件查找、日志配置都会用到应用名app这种命名在几个月后回来看会非常难受。创建完成后在config/settings.py里注册应用并做最基本的配置# 文件路径config/settings.py节选 INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, tickets, ] LANGUAGE_CODE zh-hans TIME_ZONE Asia/Shanghai USE_TZ True STATIC_URL /static/ STATIC_ROOT BASE_DIR / staticfiles MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media LOGIN_REDIRECT_URL /tickets/ LOGOUT_REDIRECT_URL /accounts/login/STATIC_ROOT和MEDIA_ROOT一定要和STATIC_URL、MEDIA_URL配套设置前者是collectstatic的收集目标后者是上传文件的落盘位置这两个目录不设置好部署阶段百分百卡住。USE_TZ True配合Asia/Shanghai时区可以保证数据库存 UTC、展示时按本地时间渲染是工单类系统强烈推荐的做法。3.3 迁移与初始数据python manage.py makemigrations tickets python manage.py migrate python manage.py createsuperuser python manage.py runserver迁移执行成功后用浏览器打开http://127.0.0.1:8000/admin/能看到默认的后台登录页就说明基础环境没问题。这一步看似简单但它是后续所有开发的前置验证锚点如果这里打不开问题一定在环境或配置层不要急着往下写业务代码。4. 十四天开发节奏拆解把一个大目标拆成每天可交付的小块是这个项目类型最核心的方法论。很多人不是不会写代码而是没有节奏前两天兴致高写了一堆第四天卡在权限上之后就再也没推进过。下面的排期不是标准答案但它符合“先骨架、再业务、后部署”的顺序每一天结束时都应该有一个能跑起来验证的产物。阶段天数交付内容验证方式项目骨架第 1-2 天虚拟环境、项目结构、settings 拆分、用户登录注册能登录并进入空首页数据建模第 3-4 天Category、Ticket、Comment 模型与迁移、admin 注册后台能手工创建工单提交与列表第 5-6 天工单创建表单、列表页、分页、关键词搜索用户能提交并看到自己的工单详情与回复第 7-8 天详情页、回复表单、内部备注、附件上传两个账号可以互相回复权限与状态第 9-10 天查询集过滤、对象级权限、状态机服务越权访问被正确拒绝日志与通知第 11 天状态变更日志、站内通知或邮件提醒每次流转都有记录测试与压测第 12 天单元测试、关键路径回归、N1 查询检查测试全部通过部署上线第 13-14 天gunicorn 常驻、Nginx 反代、静态文件、日志、备份域名可访问重启后仍正常这个排期里有两个容易被压缩掉、但绝对不该压缩的环节。第一个是第 9-10 天的权限与状态很多人觉得“先跑通再说”结果权限逻辑散落在十几个视图里后期想加一个角色就要改一遍所有函数。第二个是第 12 天的测试工单系统的状态流转分支不少手工点一遍要十几分钟写成测试后回归成本几乎为零尤其在部署前你一定会改配置改动之后能一键验证是很值的。反过来有两个环节可以适当裁剪。图表统计、富文本编辑器、邮件模板美化这些都属于“看起来加分但不影响主链路”的功能14 天的周期里优先级应该排在最后能省则省。先把“提交—受理—处理—关闭”这条主链路做扎实比页面好看更能体现工程能力。5. 核心功能实现视图、表单与权限5.1 查询集层面的权限过滤权限的第一道防线在查询集而不是模板里的{% if %}。模板判断只影响展示接口被直接访问时照样泄漏数据。下面的列表视图做了三件事按角色收缩数据范围、支持关键词搜索、支持状态筛选这三个需求在真实工单系统里几乎必然同时出现。# 文件路径tickets/views.py from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Q from django.views.generic import CreateView, DetailView, ListView from django.urls import reverse_lazy from .forms import TicketCreateForm 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): user self.request.user qs Ticket.objects.select_related(category, assignee, created_by) # 第一层按角色收缩可见范围 if not user.is_staff: qs qs.filter(Q(created_byuser) | Q(assigneeuser)) # 第二层关键词搜索 keyword self.request.GET.get(q, ).strip() if keyword: qs qs.filter(Q(title__icontainskeyword) | Q(content__icontainskeyword)) # 第三层状态筛选 status self.request.GET.get(status, ).strip() if status in Ticket.Status.values: qs qs.filter(statusstatus) return qs def get_context_data(self, **kwargs): context super().get_context_data(**kwargs) context[status_choices] Ticket.Status.choices context[current_status] self.request.GET.get(status, ) context[keyword] self.request.GET.get(q, ) return context class TicketDetailView(LoginRequiredMixin, DetailView): model Ticket template_name tickets/ticket_detail.html context_object_name ticket def get_queryset(self): user self.request.user qs Ticket.objects.select_related(category, assignee, created_by) if not user.is_staff: qs qs.filter(Q(created_byuser) | Q(assigneeuser)) return qs class TicketCreateView(LoginRequiredMixin, CreateView): model Ticket form_class TicketCreateForm template_name tickets/ticket_form.html success_url reverse_lazy(ticket-list) def form_valid(self, form): form.instance.created_by self.request.user return super().form_valid(form)这段代码里有几个值得强调的点。select_related是为了避免列表页渲染时每条工单都去查一次分类和处理人20 条数据就是 40 次额外查询这是最典型的 N1 问题。Ticket.Status.values是TextChoices提供的合法值列表用它做白名单校验可以防止有人手工构造?statusxxx这种无效参数把查询搞崩。get_queryset在详情视图里也重写了一遍这是对象级权限的关键——如果只在列表页过滤攻击者直接访问/tickets/999/依然能拿到别人的工单。5.2 表单校验与业务约束表单层承担的是“输入合法性”和“业务前置条件”的校验。下面这个表单除了基础字段还对标题长度和分类做了约束因为工单标题太短会导致后续检索困难。# 文件路径tickets/forms.py from django import forms from .models import Comment, Ticket class TicketCreateForm(forms.ModelForm): class Meta: model Ticket fields [title, content, category, priority] widgets { content: forms.Textarea( attrs{ rows: 6, placeholder: 请描述现象、复现步骤和预期结果便于快速定位问题, } ), } def clean_title(self): title self.cleaned_data[title].strip() if len(title) 6: raise forms.ValidationError(标题至少需要 6 个字符便于后续检索) return title def clean_content(self): content self.cleaned_data[content].strip() if len(content) 20: raise forms.ValidationError(问题描述请不少于 20 个字符避免信息不足导致反复沟通) return content class CommentForm(forms.ModelForm): class Meta: model Comment fields [content, is_internal] widgets {content: forms.Textarea(attrs{rows: 4})}clean_fieldname这种命名约定是 Django 表单的固定钩子返回清洗后的值抛ValidationError就会把错误挂到对应字段上并在模板里渲染。相比在视图里手写 if 判断把校验放在表单层的好处是复用性后台、接口、测试都可以用同一个表单规则只维护一份。5.3 路由配置# 文件路径config/urls.py from django.conf import settings from django.conf.urls.static import static 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(accounts/login/, auth_views.LoginView.as_view(), namelogin), path(accounts/logout/, auth_views.LogoutView.as_view(), namelogout), path(, include(tickets.urls)), ] if settings.DEBUG: urlpatterns static(settings.MEDIA_URL, document_rootsettings.MEDIA_ROOT)static()那段只在 DEBUG 为真时生效用于本地开发时提供上传文件访问生产环境必须交给 Nginx 处理否则既低效又有安全隐患。这是新手部署时最常见的一个坑本地好好的上线后附件全部 404原因就是这段代码在线上的 DEBUGFalse 下不执行而 Nginx 又没有配置/media/的转发。6. 状态流转、测试与效果验证6.1 用状态机把业务规则从视图里剥离工单系统的核心不是增删改查而是状态流转。哪些状态之间可以跳、谁能跳、跳的时候要不要检查前置条件这些问题如果写成散落在视图里的 if-else一个月后没人敢改。更稳妥的做法是定义一个允许的转移表把判断逻辑收敛到一个服务函数里并用数据库事务保证并发安全。# 文件路径tickets/services.py from django.core.exceptions import ValidationError from django.db import transaction from .models import Ticket # 键是当前状态值是可以转移到的目标状态集合 ALLOWED_TRANSITIONS { Ticket.Status.OPEN: {Ticket.Status.IN_PROGRESS, Ticket.Status.CLOSED}, Ticket.Status.IN_PROGRESS: {Ticket.Status.PENDING, Ticket.Status.RESOLVED}, Ticket.Status.PENDING: {Ticket.Status.IN_PROGRESS, Ticket.Status.CLOSED}, Ticket.Status.RESOLVED: {Ticket.Status.CLOSED, Ticket.Status.IN_PROGRESS}, Ticket.Status.CLOSED: set(), } transaction.atomic def change_status(ticket_id: int, new_status: str, operator) - Ticket: 变更工单状态包含权限校验与转移合法性校验。 ticket Ticket.objects.select_for_update().get(pkticket_id) # 权限只有处理人和管理员可以改状态 if not operator.is_staff and operator ! ticket.assignee: raise ValidationError(只有工单处理人或管理员可以变更状态) allowed ALLOWED_TRANSITIONS.get(ticket.status, set()) if new_status not in allowed: current_label dict(Ticket.Status.choices).get(ticket.status, ticket.status) target_label dict(Ticket.Status.choices).get(new_status, new_status) raise ValidationError(f不允许从「{current_label}」直接变更为「{target_label}」) ticket.status new_status ticket.save(update_fields[status, updated_at]) return ticketselect_for_update()的作用是在事务内对行加锁防止两个客服同时点击“开始处理”导致状态被覆盖。必须配合transaction.atomic使用单独的select_for_update在自动提交模式下没有意义。update_fields限定只写状态和更新时间能减少无谓的写操作也让日志更容易对齐。如果把ALLOWED_TRANSITIONS定义成类属性而不是模块级常量好处是可以通过后台配置或数据库存储实现不同业务的差异化流程。练手阶段用常量足够真实项目里遇到“不同工单类型流程不同”的需求时就该把它抽成配置表了。6.2 测试关键路径# 文件路径tickets/tests.py from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase from .models import Category, Ticket from .services import change_status User get_user_model() class StatusTransitionTests(TestCase): def setUp(self): self.category Category.objects.create(name账号问题, slugaccount) self.requester User.objects.create_user(usernamealice, passwordpwd-123456) self.agent User.objects.create_user( usernamebob, passwordpwd-123456, is_staffTrue ) self.ticket Ticket.objects.create( title登录后台提示 500 错误, content输入正确账号密码后页面报错复现步骤见附件。, categoryself.category, created_byself.requester, ) def test_open_to_resolved_is_rejected(self): with self.assertRaises(ValidationError): change_status(self.ticket.pk, Ticket.Status.RESOLVED, self.agent) def test_open_to_in_progress_succeeds(self): ticket change_status(self.ticket.pk, Ticket.Status.IN_PROGRESS, self.agent) self.assertEqual(ticket.status, Ticket.Status.IN_PROGRESS) def test_closed_ticket_cannot_reopen(self): self.ticket.status Ticket.Status.CLOSED self.ticket.save(update_fields[status]) with self.assertRaises(ValidationError): change_status(self.ticket.pk, Ticket.Status.IN_PROGRESS, self.agent)这三个用例覆盖了最典型的三类错误跳状态、正常流转、终态不可逆。测试里用setUp准备数据而不是依赖数据库里的既有记录保证每次跑测试的起点一致。用户名和密码是测试专用的假数据不要把它们当成可直接使用的账号。6.3 运行与验证# 执行全部测试 python manage.py test tickets -v 2 # 预期输出节选 # test_closed_ticket_cannot_reopen (tickets.tests.StatusTransitionTests) ... ok # test_open_to_in_progress_succeeds (tickets.tests.StatusTransitionTests) ... ok # test_open_to_resolved_is_rejected (tickets.tests.StatusTransitionTests) ... ok # Ran 3 tests in 0.021s # OK看到最后的OK和测试数量对得上说明状态机逻辑按预期工作。如果报ValidationError没被捕获通常是断言写反了如果报数据库约束错误先检查迁移是否执行完整用python manage.py showmigrations tickets看看有没有未应用的迁移。功能层面建议手工走一遍完整链路用普通账号提交一张工单用员工账号登录后在列表里筛选该工单点击开始处理再提交一条内部备注最后改成已解决再关闭。每一步都刷新一次列表页确认状态和时间戳都在变化。这条链路走通说明模型、视图、表单、权限四条线都对上了。7. 部署上线与生产环境清单7.1 环境变量与配置分离生产配置不要写死在settings.py里尤其是数据库密码和SECRET_KEY。用.env加python-dotenv是最轻的方案记得把.env加入.gitignore只提交一份.env.example给团队参考。# 文件路径.env不要提交到版本库 DJANGO_DEBUGFalse DJANGO_SECRET_KEY请替换为随机生成的字符串 DJANGO_ALLOWED_HOSTSticket.example.com DATABASE_URLpostgres://ticket_user:your_password127.0.0.1:5432/ticket_db# 文件路径config/settings.py节选 import os from pathlib import Path from dotenv import load_dotenv BASE_DIR Path(__file__).resolve().parent.parent load_dotenv(BASE_DIR / .env) DEBUG os.getenv(DJANGO_DEBUG, False) True SECRET_KEY os.getenv(DJANGO_SECRET_KEY, unsafe-default-change-me) ALLOWED_HOSTS [h for h in os.getenv(DJANGO_ALLOWED_HOSTS, ).split(,) if h] if not DEBUG: SECURE_CONTENT_TYPE_NOSNIFF True SESSION_COOKIE_SECURE True CSRF_COOKIE_SECURE True SECURE_HSTS_SECONDS 31536000ALLOWED_HOSTS为空会导致线上所有请求返回 400这是部署后最容易遇到的第一个错误排查时先看这里。上面那些SECURE_*配置在启用 HTTPS 后才有意义如果你用 HTTP 直接访问SESSION_COOKIE_SECURE会导致登录态无法保持需要按实际情况调整。7.2 使用 gunicorn 常驻# 收集静态文件 python manage.py collectstatic --noinput # 启动应用服务器前台方式便于观察日志 gunicorn config.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 3 \ --timeout 60 \ --access-logfile - \ --error-logfile ---workers的经验值是 CPU 核数的 2 倍加 1小规模用工单系统从 3 个 worker 起步完全够用。--bind绑定到 127.0.0.1 而不是 0.0.0.0是为了强制所有外部流量都经过 Nginx避免绕过反向代理直连应用端口。上线后建议用 systemd 或 supervisor 托管这个进程保证服务器重启后自动拉起。7.3 Nginx 反向代理# 文件路径/etc/nginx/conf.d/ticketing.conf upstream ticketing_app { server 127.0.0.1:8000; } server { listen 80; server_name ticket.example.com; client_max_body_size 20m; location /static/ { alias /srv/ticketing/staticfiles/; expires 30d; access_log off; } location /media/ { alias /srv/ticketing/media/; } location / { 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; proxy_pass http://ticketing_app; } }client_max_body_size要按实际附件大小设置默认 1MB 会让用户上传截图直接失败而且 Django 侧不一定给出清晰提示排查成本很高。X-Forwarded-Proto这个头在生成回调链接、判断安全 cookie 时会用到不转发会导致部分功能在 HTTPS 下行为异常。配置改完执行nginx -t校验语法再systemctl reload nginx平滑重载。验证部署是否成功按这个顺序来先用curl -I http://ticket.example.com/看状态码再看sudo tail -f /var/log/nginx/error.log有没有 upstream 连接失败然后确认 gunicorn 进程是否存在最后检查静态文件目录权限是否可读。这个顺序能让你快速定位问题出在代理层、应用层还是文件层。8. 常见问题与排查思路问题现象可能原因排查方式解决方案访问域名返回 400 Bad RequestALLOWED_HOSTS未包含域名查看 gunicorn 日志中的 DisallowedHost 提示把域名加入.env的DJANGO_ALLOWED_HOSTS页面样式全部丢失collectstatic未执行或 Nginx 路径错浏览器 404 检查/static/请求地址执行 collectstatic核对 alias 目录上传附件报 413Nginx 请求体大小限制查看 Nginx error.log调大client_max_body_size登录后立刻掉登录态启用了SESSION_COOKIE_SECURE却用 HTTP浏览器开发者工具查看 cookie 的 Secure 标记改用 HTTPS 或临时关闭该配置列表页加载很慢存在 N1 查询安装 django-debug-toolbar 看 SQL 数量补select_related/prefetch_related状态变更偶发覆盖未加事务和行锁检查是否存在并发请求使用transaction.atomicselect_for_update用户能看到别人的工单详情视图未做权限过滤直接用另一个账号访问详情 URL在get_queryset中统一收缩范围迁移时报字段已存在迁移文件与数据库不同步showmigrations查看状态回滚迁移或手工对齐django_migrations表格里最后一条需要特别提醒在生产库上直接改表结构或伪造迁移记录属于高风险操作务必先在测试库演练确认备份可用再动生产。迁移前用python manage.py sqlmigrate 应用名 迁移编号查看将要执行的 SQL是个成本极低但很有效的习惯。9. 最佳实践与后续学习方向工程规范上有几条建议都是这个项目类型里最容易掉队的地方。第一把业务规则集中到services.py视图只负责接收请求和返回响应不要让视图函数超过 50 行。第二所有涉及状态变更、指派、删除的操作都写日志哪怕只是简单的一张表事后追溯时价值极大。第三敏感操作要有明确的回滚路径比如批量关闭工单前先导出当前状态出错时能对照恢复。安全和权限方面坚持最小权限原则普通用户默认只能看自己的数据员工能看到被分配的超级管理员才拥有全局视图。对象级权限一定要在get_queryset里做不要依赖模板隐藏对外提供接口时每个接口都要重新校验一次归属关系不能假设前端传的 ID 是合法的。附件上传要限制类型和大小存储路径不要用用户提供的原始文件名避免路径穿越。性能方面优先解决 N1 问题其次是给高频查询字段加索引最后才考虑缓存。工单列表页的缓存要谨慎因为状态会频繁变化缓存不一致带来的困惑往往比性能收益更大。如果确实要缓存统计类数据设置较短的过期时间并在状态变更时主动失效。后续可以深入的方向包括把权限模型从is_staff换成基于角色的 RBAC用 Django 的权限系统或第三方库管理细粒度授权引入异步任务处理邮件通知和附件缩略图用 Django REST Framework 把核心能力开放成接口方便对接企业微信、钉钉之类的内部工具再往前一步是加上简单的 SLA 计时超时自动升级优先级。这些扩展都建立在本文这套模型和状态机之上骨架稳了加功能只是时间问题。如果你正在按 14 天的节奏推进建议把每天的成果提交到版本库并打上标签这样中途某个功能做坏了可以快速回到上一个可运行的状态。工单系统这类项目真正的难点从来不是写不出代码而是能不能在有限时间里保持一条可验证的主线不被各种“顺便加个功能”的念头带偏。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑