今年做了好几套企业内部的流程系统,最有代表性的一套是用 Django + Vue 前后端分离搭起来的工程流程控制系统。整个项目从需求梳理到上线部署,前后大概花了一个半月,踩了不少坑,也沉淀了不少可以直接复用的经验。这篇文章就是围绕这套系统展开,讲讲我当时是怎么设计技术方案、怎么写后端核心逻辑、怎么搞定前端那些“看起来简单、做起来麻烦”的事儿,以及最后在 Windows Server 上怎么用 waitress + nginx 跑起来的。
如果你正准备用 Python 做企业级应用,或者是一个刚要接触 Django 和 Vue 的开发者,这篇文章应该能帮你少走很多弯路。里面涉及的内容从 Django 的 MTV 模式到 ORM 查询删除对象,从 StreamingHttpResponse 的两个关键参数到 Vue 播放 m3u8,再到 PyCharm 的环境配置和部署上线,基本上是一条完整的实操链路。
1. 整体设计与技术选型:为什么是 Django + Vue
1.1 企业工作流系统的核心痛点
企业工作流、工程流程控制系统,听上去很高大上,拆开来看其实就是三件事:流程怎么走、数据怎么管、权限怎么控。我做的这套系统面向的是施工单位的工程报审场景,包括了施工方案审批、材料进场报验、质量巡检整改、竣工验收申请等十几个流程类型,每个流程还有若干个审批节点,比如班组提交、项目经理审核、监理复核、甲方确认。
这种系统最容易踩的坑有三个。第一是流程状态容易乱,尤其是多人同时操作同一个审批单的时候,状态被覆盖的情况非常常见。第二是文件处理麻烦,工程领域的单据经常要挂很多附件,有 PDF 图纸、有 Excel 材料清单、还有现场拍摄的视频,这些文件的存储和在线预览如果设计得不好,后期会非常痛苦。第三是权限模型复杂,同一个用户在不同流程里可能是提交人、审批人、抄送人,甚至是某个节点的代理审批人,权限判断稍微写草率一点就会出现越权操作。
所以我做这套系统的时候,没有急着写代码,而是先花了两天时间把流程建模和权限模型定下来。流程引擎这块我并没有用现成的第三方库,比如 django-workflows 或者 viewflow,而是自己写了一套轻量的状态机。原因也很简单,企业的审批流程看上去通用,实际上每个客户的规则都不一样,现成框架的定制成本有时候比直接写一个还高。自己写状态机其实不复杂,就是把流程实例的状态、当前节点、操作记录这三块表设计好,然后每次审批动作都走同一个入口函数。
1.2 为什么后端选 Django、前端选 Vue
后端选择 Django,主要看中它三个能力。第一个是 ORM 对复杂查询的支持确实省事,工程流程系统里报表和台账类的查询特别多,比如按时间段统计某类审批单的数量、按部门汇总待办事项,Django 的 ORM 用 annotate 和 aggregate 写起来非常顺手,两行代码能搞定的事情,用原生 SQL 要写一大段。第二个是 Django Admin 自带的后台管理在项目初期价值巨大,流程配置、用户权限初始化、测试数据录入,全部可以在 Admin 后台里快速完成,省掉了搭建管理后台的时间。第三个是 Django 的生态成熟,文件上传、缓存、任务队列这些在企业应用中高频出现的需求,都有现成的方案可以参考。
前端选择 Vue,说实话选的不是某个技术,而是一种开发体验。Vue 的单文件组件结构、渐进式的上手曲线、以及中文社区的大量资料,让团队里原本以 Python 为主的开发人员能快速参与前端开发。更重要的一点是 Vue 的路由和状态管理方案在前端工程化方面足够清爽,处理工作流系统里那些“同一个页面不同状态”的场景非常合适。
前后端分离是我一开始就定下的方案。Django 只提供 JSON API,前端是独立的 Vue 工程,通过 axios 调接口。这样做的好处是前后端可以并行开发,我在写后端流程引擎的时候,前端的同事已经在搭工作台页面了,联调阶段再统一对接,整体节奏快不少。
1.3 MTV 模式在工作流项目里的实际应用
说到 Django 就绕不开 MTV 模式,M 是 Model、T 是 Template、V 是 View。很多人一上来就背概念,但放在我这个项目里,理解 MTV 最好的方式是看它解决了什么问题。
Model 层负责把业务对象映射成数据表。工作流系统里最常见的 Model 是流程实例(ProcessInstance)和流程节点(Task),流程实例存的是某个报审单的完整信息,流程节点存的是当前流转到了哪一步。我建了两张主表和三张开销类子表,每张表都通过 ForeignKey 关联起来,查询的时候用 select_related 和 prefetch_related 合理控制查询次数,这套 Model 设计是整个系统的地基。
View 层在传统 Django 里是负责把 Model 查出来再渲染到 Template,但在前后端分离的项目里,Template 层已经不需要 Django 管了。你可以理解成 Vue 工程替代了 MTV 里的 Template,Django 的 View 只负责接收前端的请求、调用 Model 层逻辑、返回 JSON 给前端渲染。所以在这个项目里,MTV 变成了一个更贴合实际的“M + V + 前端模板”的组合,各层各司其职,职责边界反而更清晰了。
Value 这个东西,等真正联调起来你会发现,把 Django 的 View 保持“瘦”非常重要。流行业务的权限验证、状态流转判断、附件大小校验,这些逻辑我全部搬进了 Model 层的方法里面,View 里只做参数校验和结果返回。这样做的好处是任何入口进来的请求都走同样的业务逻辑,不管是 API 调用还是 Admin 后台触发的操作,行为都是一致的,不会出现“这里改了那里没改”的诡异问题。
2. 后端必须写好的三个关键环节:模型、ORM 查询与文件流下载
2.1 创建 App 与模型设计:流程节点表怎么建
Django 项目一创建,我做的第一件事就是拆分 App。代码上我用python manage.py startapp workflow创建了 workflow 这个 App,专门放流程引擎相关的代码,另外还有一个 accounts App 管用户和权限,一个 files App 管附件和预览。
流程相关的模型是核心,我给大家看一个简化版的设计,实际生产环境我还在这个基础上加了 UUID 主键、创建时间、更新时间这些公共字段:
class ProcessInstance(models.Model): """流程实例,一张报审单就是一个实例""" name = models.CharField(max_length=200, verbose_name='单据名称') flow_type = models.CharField(max_length=50, verbose_name='流程类型') status = models.CharField(max_length=20, default='draft', verbose_name='当前状态') submitter = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_instances', verbose_name='提交人' ) current_node = models.CharField(max_length=50, blank=True, verbose_name='当前节点') version = models.IntegerField(default=1, verbose_name='乐观锁版本号') created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = 'workflow_process_instance' indexes = [ models.Index(fields=['flow_type', 'status']), models.Index(fields=['submitter', 'created_at']), ] class NodeRecord(models.Model): """审批节点记录,每一步操作都会在这里留下痕迹""" instance = models.ForeignKey( ProcessInstance, on_delete=models.CASCADE, related_name='node_records', verbose_name='所属流程实例' ) node_name = models.CharField(max_length=100, verbose_name='节点名称') operator = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, verbose_name='操作人' ) action = models.CharField(max_length=20, verbose_name='动作') comment = models.TextField(blank=True, verbose_name='审批意见') created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = 'workflow_node_record' ordering = ['created_at']这里边有个on_delete=models.PROTECT的细节,很多人习惯不管关联关系直接写 CASCADE,但在审批系统里,用户和审批记录绝对不能级联删除。如果哪天有人要从系统里清掉一个离职员工,CASCADE 会把他审批过的所有记录一起删光,整个流程台账就毁了。所以我在用户关联的审批记录上用了 PROTECT,宁可在删除的时候报错,也不能让数据静默丢失。
version字段是我解决并发操作的关键,后面有一章我会详细讲怎么用乐观锁防止多人同时审批导致的状态覆盖。
2.2 查询、过滤与删除对象:ORM 操作的正确姿势
工程流程系统每天被点得最多的是“我的待办”和“我的已办”两个接口。待办列表的查询条件其实挺绕的:首先要找出所有当前节点指向当前用户的实例,其次要排除掉自己提交的实例,因为自己不能审批自己提交的单子,最后还要按紧急程度和提交时间排序。
Django ORM 写这类查询非常舒服,下面是我待办查询的核心代码,逻辑基本和自然语言一样:
def get_pending_instances(user): today = timezone.localdate() return ( ProcessInstance.objects .filter(current_node='pending_approval', status='approving') .exclude(submitter=user) .annotate( wait_days=Value(today) - Func( F('updated_at'), function='DATE', ) ) .order_by('-priority', 'updated_at') .select_related('submitter') .prefetch_related('node_records') ).select_related('submitter')和.prefetch_related('node_records')这两个必须写,否则列表页渲染的时候会产生十几条甚至几十条重复 SQL,Django 的 ConnectionQueries 会告诉你差距有多大。一个页面 50 条待办,不优化是 1 + 50 条查询,优化完是 L + 50 条查询里只有固定条数,性能完全两码事。
再来说说删除。Django 里单对象删除是obj.delete(),批量删除是queryset.delete(),但这里有个很容易被忽略的差异:obj.delete()返回的是(总删除数, {模型名: 删除数})这样的元组,queryset.delete()也是同样的返回结构。很多人第一次接触以为返回的是布尔值,直接拿来做判断会出问题。
我在这个项目里基本没做过真正的物理删除。审批系统的数据是有法务效力的,单据只能作废不能删除。所以我在流程实例上统一加了is_active字段,需要隐藏数据的时候执行的是伪删除:
def soft_delete_instance(self): self.is_active = False self.save(update_fields=['is_active', 'updated_at'])所有查询默认加.filter(is_active=True)过滤条件。这样做的好处是流程历史不会断,比如一张单据被误操作作废之后,管理员还能查得到原始记录,审计的时候不用翻日志。
2.3 StreamingHttpResponse 的两个关键参数:content_type 和 content-disposition
工程流程系统里有一个需求特别常见:在线下载施工方案附件,包括 PDF、Excel、图片甚至视频。最开始我图省事,直接用 Django 的 HttpResponse 把文件读进内存再返回,小文件没问题,但一旦遇到几百 MB 的现场视频,服务器内存直接被吃满,前端也迟迟等不到响应。
后来改成 StreamingHttpResponse 流式返回,性能问题迎刃而解。但这里有个坑,就是很多教程只讲了怎么用 StreamingHttpResponse,没有讲透content_type和content-disposition这两个参数到底该怎么传。
先看一个完整的下载接口实现:
import os import mimetypes from django.http import StreamingHttpResponse from urllib.parse import quote def download_attachment(request, attachment_id): att = Attachment.objects.get(pk=attachment_id) def file_iterator(file_path, chunk_size=8192): with open(file_path, 'rb') as f: while True: chunk = f.read(chunk_size) if not chunk: break yield chunk file_path = att.file.path file_name = os.path.basename(att.file.name) content_type, _ = mimetypes.guess_type(file_path) if content_type is None: content_type = 'application/octet-stream' response = StreamingHttpResponse( file_iterator(file_path), content_type=content_type, ) filename = quote(file_name) response['Content-Disposition'] = ( f"attachment; filename*=UTF-8''{filename}" ) return response这里面讲究很多。content_type是告诉浏览器响应体的媒体类型,常见的有application/pdf、application/vnd.openxmlformats-officedocument.spreadsheetml.sheet、video/mp4等等。它直接决定了浏览器收到文件之后是打开预览还是直接下载,比如 PDF 配application/pdf会在浏览器标签页里直接展示,如果你希望用户只能下载不能预览,就把它改成application/octet-stream。
至于content-disposition是 HTTP 响应头里的一个字段,不通过参数传入,而是直接对 response 对象赋值,image/svg+xml这类情况浏览器还会对下载的文件名做额外的安全检查。用attachment关键词告诉浏览器这个响应体应该被作为一个文件保存,而不是在页面里打开。文件名这里一定要用filename*=UTF-8''这种格式,因为如果文件名带中文,直接在 Content-Disposition 里写中文极有可能乱码。urllib.parse.quote会把中文转成 URL 编码,浏览器拿到之后再解码回正确文件名,这是我在 Windows 和移动端都测试过最稳的方案。
如果你用的是 Django 的 FileResponse,很多逻辑它会帮你处理,但文件前缀、文件名编码这些细节依然要自己留意。我实际项目里是这样做的:如果附件允许预览,就用inline的 Content-Disposition;如果不允许,就用attachment强制下载。
3. 前端 Vue 实战:路由、响应式与 m3u8 播放
3.1 Vue 路由如何承载工作流的不同节点状态
后端把状态流转的逻辑处理完了,前端最核心的工作其实是“状态的可视化”。同一个流程实例,不同的人打开看到的操作按钮完全不一样。提交人看到的是撤回和催办,审批人看到的是同意和驳回,抄送人只能只看不能点。
Vue Router 在我这项目里承担了页面级别的路由跳转,但流程状态之间的切换更多依赖的是组件内部的状态判断。路由上我主要设计了四个页面:待办列表页、已办列表页、流程详情页、流程发起页。详情页是整个系统最复杂的页面,因为它要根据后端返回的status和current_node动态渲染不同的操作按钮和审批流程时间线。
这里有一个经验可以分享:路由参数的传递方式不要混着用。Vue Router 有两种传参方式,一种是通过name + params传参,比如this.$router.push({ name: 'flow-detail', params: { id: workflowId } }),另一种是通过path + query传参,比如this.$router.push({ path: '/flow/detail', query: { id: workflowId } })。区别在于params方式刷页面后参数会丢失,query方式参数会保留在 URL 上但容易被用户手动篡改。
工作流详情页这种需要刷新后保持定位的页面,我统一用query方式;那种一次性的跳转参数,比如表单预填数据,才用params。如果整站统一用query带参,路由配置里还应该写上props: true,这样组件可以直接声明式接收参数,测试的时候也方便单独 mock。
3.2 从 Vue 源码到手动实现响应式:Proxy 的本质
工作流系统里最常见的交互是“表单状态驱动按钮状态”,比如申请单填完必填项才能提交,审批意见填了才能点同意按钮。这背后其实就是 Vue 的响应式系统在起作用,数据改了页面自动更新。
之前有一段时间我在分析 Vue 的响应式原理,为了搞懂它,还自己用原生 Proxy 手写过一个包含reactive、ref、effect、computed的最小实现。核心代码非常简洁,原理也很直接:
function reactive(target) { return new Proxy(target, { get(obj, key) { track(obj, key) return Reflect.get(obj, key) }, set(obj, key, value) { const result = Reflect.set(obj, key, value) trigger(obj, key) return result } }) } function ref(initialValue) { return reactive({ value: initialValue }) } function computed(getter) { let cached let dirty = true const runner = effect(getter, { lazy: true, scheduler() { dirty = true } }) return { get value() { if (dirty) { cached = runner() dirty = false } return cached } } }effect负责记录当前依赖的 key 和被依赖的副作用函数之间的映射关系,数据变化时再重新执行。computed是在effect之上加了一层缓存控制,依赖没变就用缓存值,变了才重新计算。
搞懂这套逻辑之后,再看 Vue 源码里的ref为什么偏偏要把值包在.value里就完全明白了,因为原始类型没办法被 Proxy 代理,只有对象才有捕获 get 和 set 的能力。这也是为什么业务代码里每次都要写formData.value = newData而不是直接赋值,写多了你反而会感谢这套规定,因为它让响应式边界变得清晰了。
3.3 Vue 播放 m3u8:从拿到地址到画面出现
工程流程系统里的视频巡检模块,需要在线播放施工现场上传的监控视频。录像文件普遍采用 HLS 协议,也就是.m3u8索引文件加一堆.ts切片文件的组合。问题在于,浏览器默认不支持 m3u8 格式,Safari 是个例外可以原生播放,Chrome 和 Edge 必须要引入 hls.js 才能解码。
一开始试过直接用video标签硬播 m3u8,结果要么黑屏要么只有声音没有画面。后来老老实实上了 hls.js,用法其实很简单:
<template> <video ref="video" controls autoplay muted></video> </template> <script setup> import Hls from 'hls.js' import { onMounted, ref } from 'vue' const video = ref(null) const src = 'https://your-domain/media/videos/check-record.m3u8' onMounted(() => { const videoEl = video.value if (Hls.isSupported()) { const hls = new Hls() hls.loadSource(src) hls.attachMedia(videoEl) hls.on(Hls.Events.MANIFEST_PARSED, () => { videoEl.play().catch(() => {}) }) } else if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { videoEl.src = src } }) </script>这里最容易翻车的不是前端代码,而是后端配置。hls.js 播放的时候会向服务器发起很多 Range 请求来分段拉取.ts切片,如果 Django 后端没有正确处理 Range 请求头,视频就会出现“能加载但拖不动进度条”的现象。解决方式是后端设置好响应头支持 Range 分段请求:
from django.http import FileResponse def stream_video(request, video_path): file_path = os.path.join(settings.MEDIA_ROOT, video_path) response = FileResponse(open(file_path, 'rb')) response['Accept-Ranges'] = 'bytes' return response另外跨域问题也要注意,如果 m3u8 在前端域名下,后端在另一个域名,Django 必须配置 CORS 才能让视频正常请求。我的做法是生产环境用 nginx 把视频文件路径直接代理到前端的同域名下,彻底绕开 CORS,这样简单又稳定。
4. PyCharm 环境配置与调试技巧
4.1 从安装到环境配置:一个干净的 Django 开发环境
这个项目是在 PyCharm 里开发的,团队里有几位同事是刚接触 Python 的新人,光是环境配置就问了无数问题,所以这里单独说一下。第一步是 Python 的安装,去官网下载对应系统版本,安装的时候勾选 Add Python to PATH,这一项经常被漏掉,漏掉之后命令行里输入 python 没有任何反应,后面基本没法玩。
PyCharm 建议直接装专业版,Django 支持、数据库工具、远程调试都在专业版里,社区版搞 Django 也能写,但没有模板补全和调试配置的图形化入口,效率会低不少。正式团队开发,专业版功能确实值得,教育验证或者公司采购都有正规的授权途径,不要碰破解版。
项目环境我统一用虚拟环境,在 PyCharm 新建项目的时候选择 Virtualenv,Python Interpreter 会自动指向 venv 目录下的 python.exe。这样做的核心价值是依赖隔离,每个项目的第三方库互不影响。有些同事直接用全局 Python 装了一堆包,后面升级版本的时候才发现所有项目都崩了,那就是环境没隔离的经典事故。
Django 项目配好之后,还要在 PyCharm 里配置 Run Configuration。机器上用了 develop 环境。在项目的根目录右键配置好 Python Interpreter,脚本路径指向 manage.py,参数填runserver 0.0.0.0:8000,这样点击运行按钮就能直接启动服务。
4.2 常见环境坑:Microsoft Visual C++ 14.0 is required
用 PyCharm 给项目装依赖的时候,最容易遇到的一个报错是执行安装时提示error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools"。这个报错出现的原因和 Python 本身没关系,而是因为很多包含 C 扩展的第三方包在 Windows 上没有预编译的 wheel 文件,pip 只能现场下载源码编译,编源码就要用到 VC++ 编译工具链。
我统计过,最容易触发这个报错的是pandas、lxml、pycryptodome、psycopg2这几个包,其中lxml和psycopg2在 HTTP 解析和数据库连接场景里非常常见。解决办法有两个层面。
第一个办法是优先安装预编译版本,在 PyCharm 的 Terminal 里执行安装的时候带上--only-binary :all:参数,强制要求 pip 只安装预编译的 wheel 包,这样就不会触发源码编译流程。psycopg2 的话直接装 psycopg2-binary 这个包,绝大多数场景够用。
第二个办法是安装 Visual C++ Build Tools,但这玩意安装时间很长,体积也大,我不建议为了一个包专门去装。如果项目必须用源码编译安装,可以先去查看对应包的文档找预编译版下载地址,能用 wheel 就用 wheel。
4.3 断点调试工作流状态机的技巧
PyCharm 的调试器是我排查工作流逻辑问题最依赖的工具。状态机这种代码,最难查的是“状态在某一跳转里被写错”。我举一个真实发生的 bug:审批流程从“项目经理审核”节点流到“监理复核”节点时,前端页面显示已经到监理了,但数据库里的 current_node 字段还是项目经理。
这个问题用 PyCharm 断点调试非常好定位。做法是在流转函数transition()里打上断点,然后启动调试模式,用一个测试账号模拟提交审批,逐步追踪next_node变量的赋值过程。最后发现是代码里把instance.current_node的赋值语句写到了save()之后,导致保存的还是旧值。这种错误靠代码审查不容易发现,但断点一打,变量值变化清晰可见。
PyCharm 的 Evaluate Expression 窗口在调试状态机时也特别好用,可以在断点暂停处直接执行instance.status、record.action之类的表达式,快速确认当前实例的真实状态。配合 F8 单步跳过、F7 步入函数,整个流转逻辑可以像放电影一样在脑子里过一遍,排查效率极高。
5. Windows Server 上部署 Django:waitress + nginx
5.1 为什么选 waitress 而不是默认 runserver
开发阶段用python manage.py runserver完全可以,服务会自动重载代码,改完保存就能生效,非常方便。但生产环境如果还这么干就等着被吐槽吧,runserver 是 Django 的开发服务器,性能和并发能力都不太行,也不建议暴露到公网。
在 Windows Server 上部署 Django,我推荐 waitress,它是一个纯 Python 实现的 WSGI 服务器,不需要编译 C 扩展,安装只有一个 pip 命令的事,在 Windows 环境下的表现比较稳定。之前我也试过 gunicorn,但它在 Windows 上支持有问题,官方文档明确说了建议只在 Unix 上使用,这就是我选 waitress 最直接的原因。
安装和启动方式非常简单:
pip install waitress waitress-serve --listen=127.0.0.1:8000 myproject.wsgi:applicationmyproject.wsgi:application是项目 wsgi.py 文件里暴露的 application 对象,让 waitress 加载整个 Django 应用。启动之后在浏览器访问http://127.0.0.1:8000能看到系统,说明 waitress 已经接管了 Django 服务。
为了让 waitress 在后台稳定运行,我还把它做成了一个 Windows 服务,用的是 NSSM 这个小工具,配置好启动命令和日志路径之后,服务器重启 waitress 也会自动启动,省掉了很多运维精力。
5.2 Django 静态文件与媒体文件的处理
部署阶段有个问题特别容易卡住新手:前端 Vue 构建出来的 dist 目录、Django 上传的媒体文件、Django 自带的 admin 静态资源,这三类文件在开发环境各归各的,一到生产环境就乱了。
Django 自身的静态资源处理不复杂,先在 settings.py 里配置:
STATIC_URL = '/static/' STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media')然后在服务器上执行:
python manage.py collectstatic这个命令会把所有 App 里的静态资源复制到STATIC_ROOT指定的目录下,方便 nginx 直接分配。
Vue 前端构建产物的处理路径要单独说。我在本地把前端工程构建好,产物是 dist 目录,整个目录放到服务器上,nginx 用它直接充当静态文件服务器,不需要 Django 参与。这样分工最清晰:nginx 负责 HTML、JS、CSS、图片的静态访问,waitress 负责所有 API 请求,nginx 再把/api/开头的请求反向代理给 waitress。
5.3 nginx 配置:统一入口与前端路由回退
nginx 的配置是整个部署环节里最值得花时间的部分,配好了系统运行流畅,配不好各种 404、跨域、白屏问题全来了。下面是我这套系统实际使用的 nginx 配置,核心内容都有中文注释,可以直接拿走改改用自己的。
server { listen 80; server_name your-domain.com; # 前端 Vue 构建产物 root /www/vue-dist; index index.html; # 前端路由回退,避免刷新子路由页面 404 location / { try_files $uri $uri/ /index.html; } # 静态资源缓存策略 location /assets/ { expires 7d; add_header Cache-Control "public"; } # Django 媒体文件直接走 nginx,不经过 waitress location /media/ { alias /www/django-project/media/; } # Django admin 静态资源 location /static/ { alias /www/django-project/staticfiles/; } # API 和 admin 后端请求反向代理到 waitress location /api/ { 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; # 视频文件播放必须保留这个请求头 proxy_request_buffering off; } }try_files $uri $uri/ /index.html这一行是前端路由刷新不白屏的关键。Vue 是单页应用,路由切到/flow/detail?id=123时,服务器上根本没有这个物理文件,如果不加这行,nginx 会直接返回 404,加了这行,所有不存在的路径都会回退到 index.html,交给 Vue 自己来判断路由。
proxy_request_buffering off这一行是我踩过坑之后加上的,处理视频流和文件上传请求时,如果 nginx 默认开了请求缓冲,大文件上传容易卡死,关掉之后直接把请求透传给后端,实测对超大附件的上传稳定很多。
这样配置完之后,整个系统的访问路径是统一的,用户只访问 80 端口,前端资源拉取和 API 请求都在同一个域名下,从源头规避了跨域问题,也省去了配置 CORS 的麻烦。
6. 常见问题与排查技巧实录
6.1 流程状态不同步:并发审批导致的状态覆盖
这个 bug 是我在系统上线试运行的时候发现的,两个审批人同时打开同一个单子,一个人点了同意,另一个人随后点了驳回,按理说后操作的应该提示“该单已审批”,但实际结果是第二个操作把第一个操作的结果覆盖了,流程直接跳到驳回分支,状态数据全乱。
本质原因是典型的“读改写”并发冲突,两个请求进来的时候都读到了旧状态,然后各自修改后写入数据库,后写入的把先写入的覆盖了。解决方案是乐观锁,在流程实例表里加一个 version 字段,更新之前先校验版本号:
def approve(instance_id, user_id, comment): instance = ProcessInstance.objects.select_for_update().get(pk=instance_id) updated = ProcessInstance.objects.filter( pk=instance_id, version=instance.version ).update( status='approved', version=instance.version + 1, current_node='finished' ) if not updated: raise ConflictError('该单据已被其他用户处理,请刷新页面')这里select_for_update()在数据库层面锁住了这一行,防止两个事务同时读到同一版本。如果更新失败,说明有其他请求已经改过了,直接返回冲突提示。上线之后这个并发问题就再也没出现过。
6.2 附件下载文件名乱码与响应头丢失
附件下载的乱码问题,有一部分是后端 Content-Disposition 没处理好,另一部分其实出在前端。axios 默认不会把响应体当二进制处理,直接下载会得到一堆乱码的文本文件。正确做法是在请求头里加上响应类型声明:
axios.get('/api/attachments/download', { params: { id: 123 }, responseType: 'blob' }).then(response => { const url = window.URL.createObjectURL(new Blob([response.data])) const link = document.createElement('a') link.href = url link.download = '施工方案.pdf' link.click() window.URL.revokeObjectURL(url) })写着容易,实际这个方案踩了一个坑:如果下载接口出错,比如文件不存在,后端会返回一个 JSON 错误信息,但是前端因为写了responseType: 'blob',拿到的 response.data 是一个 Blob 对象,里面包着一串 JSON 文本,导致错误提示变成乱码。后来我是这样处理的,后端把错误响应也统一用 JSON 返回,并在响应头里加一个X-Error-Code: file_not_found,前端先读取这个响应头,非 200 状态就直接从 Blob 里解析 JSON 文本拿出来用。
6.3 部署后页面白屏和接口访问失败
第一次用 nginx 部署完 Vue 项目,打开页面是一片纯白,控制台报了一堆资源 404。排查下来是 Vue 构建资源路径的问题。默认情况下 Vue 会把静态资源路径写成绝对路径,比如/js/app.js,如果 nginx 的 root 配得不对,或者前端资源没有放在域名根路径下,资源就会加载失败。
解决办法是在 vue.config.js 里把 publicPath 改成相对路径,或者精确匹配 nginx 的部署路径:
module.exports = { publicPath: './', outputDir: 'dist', productionSourceMap: false }publicPath: './'会让构建出来的 HTML 里资源路径变成相对路径,这样不管整个 dist 目录挂到哪个子路径下都能正常工作。不过要注意的是,如果同时用了 vue-router 的 history 模式,相对路径和路由路径组合起来可能会有问题,这种场景下还是建议用绝对路径,把 nginx root 配置检查清楚。
接口访问失败的问题大多数是反向代理配置不对。检查的时候先看 waitress 单独访问是否正常,再用 curl 测试 nginx 代理后的接口返回,逐层定位。大部分问题的根源都是proxy_pass后面的端口和服务地址填错了,或者后端没有监听到 nginx 所连接的那个 IP。
这套工程流程系统从设计到上线,整个过程其实就是一个“把复杂业务拆成简单模块”的练习。用户在界面上看到的是几个简单的按钮操作,背后是流程状态机、权限模型、文件存储、前端路由、服务器部署这一整条链路在支撑。Django 负责稳定地处理数据和业务逻辑,Vue 负责把流程状态和交互体验做顺畅,PyCharm 把开发调试的效率拉满,最后 waitress + nginx 组合让系统老老实实地跑在 Windows 服务器上。
对于一个做工程领域软件开发的人来说,这套组合最大的价值就是“可掌控”。Django 的生态足够成熟,Vue 的上手曲线足够平缓,PyCharm 的调试能力足够专业,把这几样工具用好,企业级的流程系统完全可以靠一个小团队保质保量地交付。希望这篇文章里的代码和踩坑经历,能帮你省下一点在深夜里查 bug 的时间。