news 2026/10/23 2:12:12

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

作者头像

张小明

前端开发工程师

1.2k 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_length=50, unique=True) slug = models.SlugField("标识", max_length=50, unique=True) 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_length=200) content = models.TextField("问题描述") category = models.ForeignKey( Category, verbose_name="分类", on_delete=models.PROTECT, related_name="tickets", ) priority = models.CharField( "优先级", max_length=10, choices=Priority.choices, default=Priority.NORMAL, ) status = models.CharField( "状态", max_length=20, choices=Status.choices, default=Status.OPEN, db_index=True, ) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name="提交人", on_delete=models.CASCADE, related_name="created_tickets", ) assignee = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name="处理人", on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_tickets", ) created_at = models.DateTimeField("创建时间", auto_now_add=True) updated_at = models.DateTimeField("更新时间", auto_now=True) 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_delete=models.CASCADE, related_name="comments" ) author = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name="回复人", on_delete=models.CASCADE) content = models.TextField("回复内容") is_internal = models.BooleanField("仅内部可见", default=False) created_at = models.DateTimeField("创建时间", auto_now_add=True) class Meta: ordering = ["created_at"] verbose_name = "工单回复" verbose_name_plural = verbose_name

这段模型里有三个细节值得展开。第一,category用了on_delete=models.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,代码风格统一用black+flake8。具体小版本号请跟随你所在团队的依赖规范,本文不做过度绑定,因为不同组织对长期支持版本的选择差异很大。虚拟环境是必须的,不要在系统 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 Django>=4.2,<6.0 psycopg[binary]>=3.1 gunicorn>=21.2 python-dotenv>=1.0 whitenoise>=6.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 天单元测试、关键路径回归、N+1 查询检查测试全部通过
部署上线第 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_by=user) | Q(assignee=user)) # 第二层:关键词搜索 keyword = self.request.GET.get("q", "").strip() if keyword: qs = qs.filter(Q(title__icontains=keyword) | Q(content__icontains=keyword)) # 第三层:状态筛选 status = self.request.GET.get("status", "").strip() if status in Ticket.Status.values: qs = qs.filter(status=status) 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_by=user) | Q(assignee=user)) 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 次额外查询,这是最典型的 N+1 问题。Ticket.Status.values是TextChoices提供的合法值列表,用它做白名单校验,可以防止有人手工构造?status=xxx这种无效参数把查询搞崩。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(), name="login"), path("accounts/logout/", auth_views.LogoutView.as_view(), name="logout"), path("", include("tickets.urls")), ] if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

static()那段只在 DEBUG 为真时生效,用于本地开发时提供上传文件访问,生产环境必须交给 Nginx 处理,否则既低效又有安全隐患。这是新手部署时最常见的一个坑:本地好好的,上线后附件全部 404,原因就是这段代码在线上的 DEBUG=False 下不执行,而 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(pk=ticket_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 ticket

select_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="账号问题", slug="account") self.requester = User.objects.create_user(username="alice", password="pwd-123456") self.agent = User.objects.create_user( username="bob", password="pwd-123456", is_staff=True ) self.ticket = Ticket.objects.create( title="登录后台提示 500 错误", content="输入正确账号密码后页面报错,复现步骤见附件。", category=self.category, created_by=self.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_DEBUG=False DJANGO_SECRET_KEY=请替换为随机生成的字符串 DJANGO_ALLOWED_HOSTS=ticket.example.com DATABASE_URL=postgres://ticket_user:your_password@127.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 = 31536000

ALLOWED_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 或临时关闭该配置
列表页加载很慢存在 N+1 查询安装 django-debug-toolbar 看 SQL 数量补select_related/prefetch_related
状态变更偶发覆盖未加事务和行锁检查是否存在并发请求使用transaction.atomic+select_for_update
用户能看到别人的工单详情视图未做权限过滤直接用另一个账号访问详情 URL在get_queryset中统一收缩范围
迁移时报字段已存在迁移文件与数据库不同步showmigrations查看状态回滚迁移或手工对齐django_migrations

表格里最后一条需要特别提醒:在生产库上直接改表结构或伪造迁移记录,属于高风险操作,务必先在测试库演练,确认备份可用再动生产。迁移前用python manage.py sqlmigrate 应用名 迁移编号查看将要执行的 SQL,是个成本极低但很有效的习惯。

9. 最佳实践与后续学习方向

工程规范上有几条建议,都是这个项目类型里最容易掉队的地方。第一,把业务规则集中到services.py,视图只负责接收请求和返回响应,不要让视图函数超过 50 行。第二,所有涉及状态变更、指派、删除的操作都写日志,哪怕只是简单的一张表,事后追溯时价值极大。第三,敏感操作要有明确的回滚路径,比如批量关闭工单前先导出当前状态,出错时能对照恢复。

安全和权限方面,坚持最小权限原则:普通用户默认只能看自己的数据,员工能看到被分配的,超级管理员才拥有全局视图。对象级权限一定要在get_queryset里做,不要依赖模板隐藏;对外提供接口时,每个接口都要重新校验一次归属关系,不能假设前端传的 ID 是合法的。附件上传要限制类型和大小,存储路径不要用用户提供的原始文件名,避免路径穿越。

性能方面,优先解决 N+1 问题,其次是给高频查询字段加索引,最后才考虑缓存。工单列表页的缓存要谨慎,因为状态会频繁变化,缓存不一致带来的困惑往往比性能收益更大。如果确实要缓存统计类数据,设置较短的过期时间,并在状态变更时主动失效。

后续可以深入的方向包括:把权限模型从is_staff换成基于角色的 RBAC,用 Django 的权限系统或第三方库管理细粒度授权;引入异步任务处理邮件通知和附件缩略图;用 Django REST Framework 把核心能力开放成接口,方便对接企业微信、钉钉之类的内部工具;再往前一步是加上简单的 SLA 计时,超时自动升级优先级。这些扩展都建立在本文这套模型和状态机之上,骨架稳了,加功能只是时间问题。

如果你正在按 14 天的节奏推进,建议把每天的成果提交到版本库并打上标签,这样中途某个功能做坏了可以快速回到上一个可运行的状态。工单系统这类项目真正的难点从来不是写不出代码,而是能不能在有限时间里保持一条可验证的主线,不被各种“顺便加个功能”的念头带偏。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 2:11:25

10分钟跑通OpenDLSS-NR:Windows编译构建与运行完整教程

【免费下载链接】OpenDLSS-NR A Vulkan reimplementation of NVIDIAs DLSS 5 Neural Rendering network, bit-exact against the original. 项目地址&#xff1a; https://gitcode.com/gh_mirrors/op/OpenDLSS-NR 点击查看 免费下载 OpenDLSS-NR 是一个基于 Vulkan 对 NVIDIA …

作者头像 李华
网站建设 2026/10/23 2:08:03

虚拟线程在Web应用中的应用:实战案例

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华