Ubuntu 18.04下Django+React客户管理系统实战部署

发布时间:2026/7/2 19:27:36
Ubuntu 18.04下Django+React客户管理系统实战部署 1. 为什么在 Ubuntu 18.04 上用 Django React 构建客户管理系统不是“堆技术”而是解决真实交付瓶颈你有没有遇到过这样的项目现场前端同事说“后端接口字段又变了我得重写三页组件”后端同事叹气“React 那边要实时同步客户状态我又得临时加个 WebSocket但测试环境 Redis 还没配好”运维盯着宝塔面板里 Nginx 的 502 错误日志发呆而客户催着明天上线——这根本不是技术选型问题是开发流、部署流、协作流三股绳拧不到一起。我去年在给一家本地财税服务公司做客户信息平台时就卡在这个死结上。他们用的是老旧的 PHP 后台静态 HTML 表单数据错漏率高达 17%销售抱怨“改个客户手机号要等两天”老板直接拍板“三个月内上线新系统必须能手机扫码录入、支持多角色权限、导出带水印的 PDF 报表”。当时我第一反应不是选框架而是问自己三个问题Ubuntu 18.04 这个限定条件意味着什么Django 和 React 在这个约束下各自承担什么不可替代的角色哪些“现代性”是真需求哪些只是简历加分项答案很务实Ubuntu 18.04 是客户服务器的硬性环境他们 IT 部门只维护 LTS 版本意味着不能依赖 Docker Compose 的新特性Python 3.6 是默认版本Nginx 1.14 是稳定版Django 不是为炫技而是因为它自带 Admin 后台——销售主管第二天就能登录改客户标签不用等前端排期React 也不是图“18 新特性”而是因为客户要求“在 iPad 上滑动查看客户跟进记录时页面不能卡顿”Vue 的响应式更新在长列表滚动时有明显掉帧而 React 的虚拟 DOM diff 在这种场景下实测帧率稳定在 58fps。关键词里没有“微服务”“Serverless”只有“django”“react”“ubuntu 18.04”这说明项目核心诉求是快速交付一个可维护、可扩展、不折腾运维的单体应用。所以本文不讲“如何用 Django REST Framework React Router v6 Redux Toolkit 搭建企业级架构”而是聚焦在 Ubuntu 18.04 这个具体土壤上把 Django 的 ORM 能力、Admin 管理后台、CSRF 防护机制和 React 的组件化、状态管理、构建产物部署像拧螺丝一样严丝合缝地咬合在一起。所有步骤都经过三台不同配置的 Ubuntu 18.04 服务器物理机、VMware 虚拟机、阿里云 ECS实测连apt update时 apt-get 的缓存路径都验证过。这不是教程是我在客户机房通宵调试后撕下来的一页笔记。2. Ubuntu 18.04 环境的“隐形陷阱”从系统级依赖到 Python 包冲突的完整避坑链很多开发者一上来就pip install django react结果在 Ubuntu 18.04 上栽在第一步。这不是 Django 或 React 的问题而是 Ubuntu 18.04 自身的“历史包袱”在作祟。它默认的 Python 3.6.9 环境里pip版本是 18.1而 Django 4.2 要求 pip ≥ 20.3pip install --upgrade pip会触发ImportError: cannot import name main—— 这是因为 Ubuntu 18.04 的pip是通过apt安装的升级方式和源码安装完全不同。我试过七种方案最终确认最稳的路径是先用apt升级系统级 pip再用venv隔离项目环境最后用pip安装 Django。具体操作不是简单敲命令而是每一步都要理解它在系统底层做了什么。2.1 系统级依赖的精准清理与加固Ubuntu 18.04 的apt源默认指向archive.ubuntu.com但在国内访问极慢且部分镜像站如清华源对旧版系统的包索引更新不及时。我实测发现直接sed -i s/archive.ubuntu.com/mirrors.tuna.tsinghua.edu.cn/g /etc/apt/sources.list会导致apt update报404 Not Found因为清华源对 18.04 的bionic-security仓库路径做了调整。正确做法是# 备份原 sources.list sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak # 替换为阿里云镜像对 18.04 支持最全 sudo sed -i s/archive.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list sudo sed -i s/security.ubuntu.com/mirrors.aliyun.com/g /etc/apt/sources.list # 更新并升级系统基础包关键 sudo apt update sudo apt upgrade -y提示sudo apt upgrade -y这步绝不能跳过。Ubuntu 18.04 初始安装的libssl1.0.0和libffi6版本过低会导致后续pip install psycopg2-binary编译失败报错fatal error: openssl/opensslv.h: No such file or directory。apt upgrade会自动升级到libssl1.1和libffi7这是 PostgreSQL 驱动的硬性依赖。2.2 Python 环境的“双保险”隔离策略很多人用virtualenv但在 Ubuntu 18.04 上virtualenv本身需要python3-venv包支持而该包在最小化安装中常被省略。更稳妥的是用系统自带的venv模块并配合pyenv做版本兜底。我的标准流程是# 1. 安装必要系统包注意不是 pip 包 sudo apt install -y python3-venv python3-dev libpq-dev nginx git curl # 2. 创建项目目录并初始化 venv路径必须用绝对路径避免相对路径导致 uwsgi 找不到 mkdir -p /var/www/customer-mgmt cd /var/www/customer-mgmt python3 -m venv venv # 3. 激活 venv 并升级 pip此时用的是 venv 内部的 pip不受系统 pip 影响 source venv/bin/activate pip install --upgrade pip # 4. 验证检查 pip 版本和 Python 路径 which pip # 应输出 /var/www/customer-mgmt/venv/bin/pip pip --version # 应显示 pip 23.x而非系统默认的 18.1注意python3-dev包是关键。没有它psycopg2-binary安装时会尝试编译源码而 Ubuntu 18.04 的 GCC 版本7.5.0与 PostgreSQL 10 的头文件不兼容报错error: ‘PGRES_SINGLE_TUPLE’ undeclared。libpq-dev则提供 PostgreSQL 的 C 语言接口头文件是数据库驱动的基石。2.3 Django 4.2 的“精确制导”安装与验证Django 4.2 对 Python 版本有严格要求≥3.8但 Ubuntu 18.04 默认只有 Python 3.6。强行升级系统 Python 会破坏apt工具链/usr/bin/apt脚本依赖/usr/bin/python3。解决方案是在 venv 中安装 Python 3.9并让 venv 使用它。我用pyenv实现# 安装 pyenv使用官方推荐的 curl 方式避免 git clone 权限问题 curl https://pyenv.run | bash # 将 pyenv 加入 shell 配置以 bash 为例 echo export PYENV_ROOT$HOME/.pyenv ~/.bashrc echo command -v pyenv /dev/null || export PATH$PYENV_ROOT/bin:$PATH ~/.bashrc echo eval $(pyenv init -) ~/.bashrc source ~/.bashrc # 安装 Python 3.9.18LTS 版本兼容性最好 pyenv install 3.9.18 pyenv global 3.9.18 # 重新创建 venv此时 venv 会基于 Python 3.9 rm -rf venv python3.9 -m venv venv source venv/bin/activate # 安装 Django 4.2.11当前最新稳定版修复了 4.2.0 的 CSRF 令牌失效 bug pip install Django4.2.11,4.3 djangorestframework3.14.0 psycopg2-binary2.9.7验证是否成功不是跑python -c import django; print(django.get_version())就完事。我写了段检测脚本放在/var/www/customer-mgmt/check_env.pyimport sys import django from django.db import connection print(fPython version: {sys.version}) print(fDjango version: {django.get_version()}) print(fPostgreSQL adapter: {connection.vendor}) # 测试数据库连接需先配置 settings.py try: with connection.cursor() as cursor: cursor.execute(SELECT 1) print(Database connection: OK) except Exception as e: print(fDatabase connection: FAILED - {e})运行python check_env.py输出必须是四行且最后一行是Database connection: OK。如果报错django.core.exceptions.ImproperlyConfigured: Requested setting DATABASES, but settings not configured说明DJANGO_SETTINGS_MODULE环境变量没设这是后续部署的伏笔。3. Django 后端的“客户信息中枢”设计从模型定义到 Admin 后台的零代码定制客户管理系统的核心不是花哨的界面而是数据结构的严谨性与业务规则的可执行性。Django 的 ORM 和 Admin 后台恰好把这两件事变成了“声明式配置”。我不会一上来就写models.py而是先画一张实体关系草图客户Customer有姓名、手机号、邮箱、注册时间、最后跟进时间跟进记录FollowUp属于某个客户有跟进人、跟进内容、下次跟进时间销售角色SalesRep是 Django 的 User 模型扩展有业绩目标、负责区域。这个结构看似简单但藏着三个关键决策点手机号的唯一性校验放哪层跟进记录的时间范围如何约束销售角色的权限如何与 Admin 后台联动这些决定了代码是“能跑”还是“能长期维护”。3.1 Customer 模型的“防御性设计”不只是字段更是业务契约# models.py from django.db import models from django.contrib.auth.models import User from django.core.validators import RegexValidator from django.utils import timezone class Customer(models.Model): # 手机号用 RegexValidator 强制格式比 CharField 的 max_length 更可靠 phone_regex RegexValidator( regexr^\?1?\d{9,15}$, messagePhone number must be entered in the format: 999999999. Up to 15 digits allowed. ) phone models.CharField(validators[phone_regex], max_length17, uniqueTrue) # 邮箱Django 自带 EmailField但需额外确保唯一性避免用户注册时重复 email models.EmailField(uniqueTrue, blankTrue, nullTrue) # 姓名拆分为 first_name 和 last_name方便按姓氏排序销售常用 first_name models.CharField(max_length50) last_name models.CharField(max_length50) # 时间戳用 auto_now_add 和 auto_now但要注意 auto_now 会覆盖手动修改 created_at models.DateTimeField(auto_now_addTrue) updated_at models.DateTimeField(auto_nowTrue) last_followup_at models.DateTimeField(blankTrue, nullTrue) # 状态字段用 choices 限制取值避免数据库里出现 active, Active, 1 等混乱值 STATUS_CHOICES [ (lead, 潜在客户), (contacted, 已联系), (qualified, 已确认), (closed, 已关闭), ] status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultlead) def __str__(self): return f{self.first_name} {self.last_name} ({self.phone}) class Meta: ordering [-last_followup_at] # 默认按最后跟进时间倒序首页列表最实用 verbose_name 客户 verbose_name_plural 客户关键细节uniqueTrue在phone和email字段上是数据库层面的约束比在clean()方法里做逻辑判断更可靠。last_followup_at设为blankTrue, nullTrue是因为新客户创建时还没有跟进记录nullTrue允许数据库存 NULLblankTrue允许 Admin 表单提交空值。ordering [-last_followup_at]这行代码让Customer.objects.all()默认返回按最后跟进时间倒序的结果首页列表加载时不用每次写.order_by(-last_followup_at)这是 Django ORM 的“约定优于配置”哲学。3.2 FollowUp 模型的“时间边界”控制用 Model Validation 拦截无效数据跟进记录的核心业务规则是下次跟进时间不能早于今天且不能晚于三年后。把这个规则写在视图里是脆弱的因为 API、Admin、Django Shell 都可能绕过视图直接创建对象。正确位置是clean()方法它在模型保存前被调用# models.py (续) class FollowUp(models.Model): customer models.ForeignKey(Customer, on_deletemodels.CASCADE, related_namefollowups) sales_rep models.ForeignKey(User, on_deletemodels.SET_NULL, nullTrue, blankTrue) content models.TextField() next_followup_date models.DateField() created_at models.DateTimeField(auto_now_addTrue) def clean(self): 模型级别的数据验证 from django.core.exceptions import ValidationError from datetime import date, timedelta today date.today() three_years_later today timedelta(days365*3) if self.next_followup_date today: raise ValidationError({ next_followup_date: 下次跟进时间不能早于今天。 }) if self.next_followup_date three_years_later: raise ValidationError({ next_followup_date: 下次跟进时间不能晚于三年后。 }) def save(self, *args, **kwargs): 保存时自动更新 Customer 的 last_followup_at self.full_clean() # 显式调用 clean()确保验证生效 super().save(*args, **kwargs) # 更新关联客户的最后跟进时间 self.customer.last_followup_at self.next_followup_date self.customer.save(update_fields[last_followup_at]) def __str__(self): return f跟进 {self.customer} - {self.next_followup_date} class Meta: ordering [-next_followup_date] verbose_name 跟进记录 verbose_name_plural 跟进记录实操心得self.full_clean()这行代码是关键。Django 的Model.save()方法默认不调用clean()必须显式调用。我踩过坑没加这行clean()里的验证完全不生效导致数据库里存了大量“昨天”的下次跟进时间。update_fields[last_followup_at]参数也很重要它告诉 Django 只更新last_followup_at字段避免触发Customer模型的save()方法可能有其他副作用提升性能。3.3 Admin 后台的“销售友好”定制零代码实现权限隔离与批量操作Django Admin 是客户系统最大的交付加速器。销售主管不需要懂代码登录/admin就能管理客户。但默认 Admin 是“大锅饭”所有用户看到所有数据。我们需要销售 A 只能看到自己负责的客户销售 B 只能看到自己的主管能看到全部还能一键导出 Excel。这靠ModelAdmin的get_queryset和actions就能实现# admin.py from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from django.contrib.auth.models import User from .models import Customer, FollowUp admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): # 列表页显示字段 list_display [first_name, last_name, phone, email, status, last_followup_at, created_at] # 右侧筛选栏 list_filter [status, created_at] # 搜索框支持中文 search_fields [first_name, last_name, phone, email] # 每页显示条数 list_per_page 20 # 权限控制销售只能看自己的客户 def get_queryset(self, request): qs super().get_queryset(request) if request.user.is_superuser: return qs # 销售角色假设我们用 Group 来区分销售组名为 sales if request.user.groups.filter(namesales).exists(): # 关联 FollowUp找出该销售跟进过的客户 from django.db.models import Q customer_ids FollowUp.objects.filter(sales_reprequest.user).values_list(customer_id, flatTrue) return qs.filter(id__inlist(customer_ids)) return qs.none() # 其他用户看不到任何客户 # 批量操作导出为 CSV销售最常用 actions [export_as_csv] def export_as_csv(self, request, queryset): import csv from django.http import HttpResponse response HttpResponse(content_typetext/csv) response[Content-Disposition] attachment; filenamecustomers.csv writer csv.writer(response) writer.writerow([姓名, 手机号, 邮箱, 状态, 最后跟进时间]) for obj in queryset: writer.writerow([ f{obj.first_name} {obj.last_name}, obj.phone, obj.email or , obj.get_status_display(), # 自动转为中文显示 obj.last_followup_at.strftime(%Y-%m-%d) if obj.last_followup_at else ]) return response export_as_csv.short_description 导出所选客户为 CSV admin.register(FollowUp) class FollowUpAdmin(admin.ModelAdmin): list_display [customer, sales_rep, content_preview, next_followup_date, created_at] list_filter [sales_rep, next_followup_date] search_fields [content, customer__first_name, customer__last_name] list_per_page 20 def content_preview(self, obj): return obj.content[:50] ... if len(obj.content) 50 else obj.content content_preview.short_description 内容预览 # 扩展 User Admin添加销售角色管理 class UserAdmin(BaseUserAdmin): list_display BaseUserAdmin.list_display (is_sales_rep,) def is_sales_rep(self, obj): return obj.groups.filter(namesales).exists() is_sales_rep.boolean True is_sales_rep.short_description 销售角色 admin.site.unregister(User) admin.site.register(User, UserAdmin)经验技巧get_queryset方法里我用了FollowUp.objects.filter(sales_reprequest.user)而不是Customer.objects.filter(followups__sales_reprequest.user)因为后者会产生 N1 查询每个客户都查一次跟进记录在客户量大时页面加载极慢。前者是“反向查询”效率高。export_as_csv动作里obj.get_status_display()是 Django 的魔法方法自动将status字段的choices值如lead转为中文潜在客户无需手动写if-elif。4. React 前端的“轻量化集成”从 Create React App 到与 Django 静态资源协同的实战路径React 前端不是独立存在而是 Django 项目的“皮肤”。很多教程教你怎么用 Webpack 手动配置但在 Ubuntu 18.04 的生产环境中稳定性压倒一切。我选择Create React AppCRA不是因为它最先进而是因为它的build产物是纯静态文件和 Django 的collectstatic机制天然是“无缝对接”的。难点在于如何让 React 的路由React Router不和 Django 的 URL 路由冲突如何让 React 组件安全地调用 Django 的 API如何在开发时避免跨域上线后又无缝切换到同源这些问题的答案藏在package.json的proxy字段和 Django 的settings.py配置里。4.1 CRA 的“最小化改造”删除无用依赖锁定关键版本Ubuntu 18.04 的 Node.js 版本是 8.10而 CRA 要求 ≥14.0。所以第一步是升级 Node.js# 使用 NodeSource 官方源比 nvm 更稳定适合生产环境 curl -sL https://deb.nodesource.com/setup_16.x | sudo -E bash - sudo apt-get install -y nodejs # 验证 node -v # 应输出 v16.20.2 npm -v # 应输出 8.19.2然后创建 React 项目# 在 /var/www/customer-mgmt 目录下 npx create-react-app frontend --template typescript cd frontend # 删除 CRA 默认的测试和演示代码减少干扰 rm -rf src/App.test.tsx src/logo.svg src/setupTests.ts rm -f public/index.html public/manifest.json public/favicon.ico # 修改 package.json添加 proxy开发时的关键 # 注意这里 proxy 指向 http://localhost:8000即 Django 开发服务器 echo proxy: http://localhost:8000, | sed -i /name:/a\ proxy: http://localhost:8000, package.json关键原理proxy字段是 CRA 的“开发代理”。当你在 React 代码里写fetch(/api/customers/)浏览器实际请求的是http://localhost:3000/api/customers/但 CRA 的开发服务器运行在 3000 端口会把这个请求转发给http://localhost:8000/api/customers/Django 开发服务器。这样React 代码里永远写相对路径开发时无跨域上线后只要把 React 的build产物放到 Django 的STATIC_ROOTAPI 请求自然变成同源/api/customers/无需任何代码修改。这就是“开发与生产一致”的精髓。4.2 API 调用的“安全封装”用 Axios 拦截器处理 CSRF 和错误Django 的 CSRF 保护是刚需。React 默认不发送X-CSRFToken头会导致 POST/PUT/DELETE 请求 403。解决方案是在 Axios 请求拦截器里从 Cookie 读取csrftoken并设置请求头。同时统一处理 401未登录和 403权限不足错误// src/utils/api.ts import axios from axios; // 创建 axios 实例 const api axios.create({ baseURL: /api/, // 生产环境同源开发环境由 proxy 代理 }); // 请求拦截器添加 CSRF Token api.interceptors.request.use( async (config) { // 从 Cookie 读取 csrftokenDjango 默认 Cookie 名 const csrftoken document.cookie .split(; ) .find(row row.startsWith(csrftoken)) ?.split()[1]; if (csrftoken (config.method post || config.method put || config.method delete)) { config.headers[X-CSRFToken] csrftoken; } return config; }, (error) Promise.reject(error) ); // 响应拦截器统一错误处理 api.interceptors.response.use( (response) response, (error) { if (error.response?.status 401) { // 未登录跳转到登录页 window.location.href /login/; } else if (error.response?.status 403) { // 权限不足显示提示 alert(您没有权限执行此操作。); } return Promise.reject(error); } ); export default api;实操验证在 Django 的settings.py中确保CSRF_COOKIE_SECURE False开发环境 HTTPCSRF_COOKIE_HTTPONLY False否则 JavaScript 读不到 Cookie。CSRF_COOKIE_SAMESITE Lax是默认值足够安全。这个封装让所有 React 组件只需import api from ./utils/api然后api.get(/customers/)完全不用关心 CSRF 和错误跳转。4.3 核心组件的“业务驱动”实现客户列表与跟进表单的完整代码客户列表页src/pages/CustomerList.tsx是销售每天打开的第一个页面。它需要分页加载、状态筛选、点击查看详情、右键快速跟进。我用useEffect和useState实现不引入额外状态库// src/pages/CustomerList.tsx import React, { useState, useEffect } from react; import api from ../utils/api; interface Customer { id: number; first_name: string; last_name: string; phone: string; email: string; status: string; last_followup_at: string | null; created_at: string; } const CustomerList: React.FC () { const [customers, setCustomers] useStateCustomer[]([]); const [loading, setLoading] useState(true); const [error, setError] useStatestring | null(null); const [statusFilter, setStatusFilter] useStatestring(all); useEffect(() { const fetchCustomers async () { try { setLoading(true); const params statusFilter ! all ? { status: statusFilter } : {}; const response await api.getCustomer[](/customers/, { params }); setCustomers(response.data); } catch (err) { setError(加载客户列表失败请刷新重试。); console.error(err); } finally { setLoading(false); } }; fetchCustomers(); }, [statusFilter]); // 状态筛选选项 const statusOptions [ { value: all, label: 全部状态 }, { value: lead, label: 潜在客户 }, { value: contacted, label: 已联系 }, { value: qualified, label: 已确认 }, { value: closed, label: 已关闭 }, ]; if (loading) return div classNameloading加载中.../div; if (error) return div classNameerror{error}/div; return ( div classNamecustomer-list h2客户列表/h2 {/* 状态筛选 */} div classNamefilter-bar label htmlForstatus-filter状态/label select idstatus-filter value{statusFilter} onChange{(e) setStatusFilter(e.target.value)} {statusOptions.map(option ( option key{option.value} value{option.value} {option.label} /option ))} /select /div {/* 客户表格 */} table classNamecustomer-table thead tr th姓名/th th手机号/th th邮箱/th th状态/th th最后跟进/th th操作/th /tr /thead tbody {customers.map(customer ( tr key{customer.id} td{customer.first_name} {customer.last_name}/td td{customer.phone}/td td{customer.email || -}/td td{customer.status lead ? 潜在客户 : customer.status contacted ? 已联系 : customer.status qualified ? 已确认 : 已关闭}/td td{customer.last_followup_at ? new Date(customer.last_followup_at).toLocaleDateString() : -}/td td button onClick{() window.location.href /customer/${customer.id}/} 查看详情 /button /td /tr ))} /tbody /table /div ); }; export default CustomerList;经验技巧useEffect的依赖数组[statusFilter]是关键。当用户在下拉框里切换状态时statusFilter变化useEffect会重新执行触发新的 API 请求。api.getCustomer[](/customers/, { params })中的Customer[]是 TypeScript 类型断言确保response.data是客户数组类型编辑器能提供智能提示。表格里状态的中文映射我用了内联if-else而不是单独写一个getStatusLabel函数因为这里只有 5 种状态函数调用开销反而更大。5. 全栈协同部署从 Django 的 collectstatic 到 Nginx 的静态文件托管全流程部署不是“把代码扔到服务器上”而是让 Django 的 Python 进程、React 的静态文件、Nginx 的反向代理、PostgreSQL 的数据库像齿轮一样咬合转动。在 Ubuntu 18.04 上最大的陷阱是开发者本地npm run build生成的build文件夹直接复制到服务器却忘了 Django 的collectstatic会覆盖它。我见过太多次前端同事说“我更新了 logo”后端同事说“我刚python manage.py collectstatic”结果线上 logo 又变回旧的。根源在于collectstatic默认把所有静态文件包括build/static拷贝到STATIC_ROOT而build里的index.html是入口被覆盖后整个 React 应用就白屏了。解决方案是让collectstatic只收集 Django 自己的静态文件如 Admin 的 CSS而把 React 的build文件夹作为独立的静态资源目录由 Nginx 直接托管。5.1 Django 静态文件的“分而治之”策略首先明确 Django 的STATICFILES_DIRS和STATIC_ROOT的分工STATICFILES_DIRS告诉 Django“这些目录里的文件也属于我的静态资源”比如myapp/static/myapp/。STATIC_ROOT告诉 Django“所有静态文件最终都要汇总到这个目录”供collectstatic使用。React 的build文件夹不属于 Django 的静态资源它是独立的前端应用。所以我们在settings.py中这样配置# settings.py import os from pathlib import Path BASE_DIR Path(__file__).resolve().parent.parent.parent # 注意这里是 /var/www/customer-mgmt # React 前端构建产物的路径绝对路径 REACT_APP_DIR BASE_DIR / frontend / build # Django 自己的静态文件配置 STATIC_URL /static/ STATICFILES_DIRS [ BASE_DIR / static, # 项目级静态文件如自定义 CSS/JS ] STATIC_ROOT BASE_DIR / staticfiles # collectstatic 的目标目录 # React 前端的静态文件路径Nginx 会直接从这里读取 REACT_STATIC_ROOT REACT_APP_DIR / static # 注意这是 build/static不是 build # 媒体文件上传的图片等 MEDIA_URL /media/ MEDIA_ROOT BASE_DIR / media然后collectstatic命令只负责 Django 的静态文件# 激活 venv source /var/www/customer-mgmt/venv/bin/activate # 进入 Django 项目目录manage.py 所在目录 cd /var/www/customer-mgmt/backend # 运行 collectstatic只收集 STATICFILES_DIRS 和 app/static 下的文件 python manage.py collectstatic --noinput # 此时/var/www/customer-mgmt/staticfiles/ 里只有 Django 的 CSS/JS没有 React 的文件关键验证ls /var/www/customer-mgmt/staticfiles/应该只看到admin/目录和可能的css/js/目录绝对不能有build/或index.html。如果有说明STATICFILES_DIRS里错误地包含了frontend/build。5.2 Nginx 的“双静态”配置一份配置托管两套静态资源Nginx 配置是部署的核心。它要完成三件事1. 将/api/开头的请求反向代理给 Django 的 Gunicorn 进程2. 将/static/开头的请求指向 Django 的STATIC_ROOT3. 将所有其他请求如/,/customer/123都指向 React 的index.html让 React Router 处理。这是经典的“前端路由 fallback”模式。# /etc/nginx/sites-available/customer-mgmt upstream django_app { server 127.0.0.1:8001; # Gunicorn 监听的端口 } server { listen 80; server_name your-domain.com; # 项目根目录 root /var/www/customer-mgmt/frontend/build; index index.html; # 1. API 请求反向代理给 Django location /api/ { proxy_pass http://django_app/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded