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.3,pip 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.18(LTS 版本,兼容性最好) 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 "Django>=4.2.11,<4.3" "djangorestframework==3.14.0" "psycopg2-binary==2.9.7"验证是否成功,不是跑python -c "import django; print(django.get_version())"就完事。我写了段检测脚本,放在/var/www/customer-mgmt/check_env.py:
import sys import django from django.db import connection print(f"Python version: {sys.version}") print(f"Django version: {django.get_version()}") print(f"PostgreSQL adapter: {connection.vendor}") # 测试数据库连接(需先配置 settings.py) try: with connection.cursor() as cursor: cursor.execute("SELECT 1") print("Database connection: OK") except Exception as e: print(f"Database 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( regex=r'^\+?1?\d{9,15}$', message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed." ) phone = models.CharField(validators=[phone_regex], max_length=17, unique=True) # 邮箱:Django 自带 EmailField,但需额外确保唯一性(避免用户注册时重复) email = models.EmailField(unique=True, blank=True, null=True) # 姓名:拆分为 first_name 和 last_name,方便按姓氏排序(销售常用) first_name = models.CharField(max_length=50) last_name = models.CharField(max_length=50) # 时间戳:用 auto_now_add 和 auto_now,但要注意 auto_now 会覆盖手动修改 created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) last_followup_at = models.DateTimeField(blank=True, null=True) # 状态字段:用 choices 限制取值,避免数据库里出现 'active', 'Active', '1' 等混乱值 STATUS_CHOICES = [ ('lead', '潜在客户'), ('contacted', '已联系'), ('qualified', '已确认'), ('closed', '已关闭'), ] status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='lead') def __str__(self): return f"{self.first_name} {self.last_name} ({self.phone})" class Meta: ordering = ['-last_followup_at'] # 默认按最后跟进时间倒序,首页列表最实用 verbose_name = "客户" verbose_name_plural = "客户"关键细节:
unique=True在phone和clean()方法里做逻辑判断更可靠。last_followup_at设为blank=True, null=True,是因为新客户创建时还没有跟进记录,null=True允许数据库存 NULL,blank=True允许 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_delete=models.CASCADE, related_name='followups') sales_rep = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) content = models.TextField() next_followup_date = models.DateField() created_at = models.DateTimeField(auto_now_add=True) def clean(self): """模型级别的数据验证""" from django.core.exceptions import ValidationError from datetime import date, timedelta today = date.today() three_years_later = today + timedelta(days=365*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(name='sales').exists(): # 关联 FollowUp,找出该销售跟进过的客户 from django.db.models import Q customer_ids = FollowUp.objects.filter(sales_rep=request.user).values_list('customer_id', flat=True) return qs.filter(id__in=list(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_type='text/csv') response['Content-Disposition'] = 'attachment; filename="customers.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(name='sales').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_rep=request.user)而不是Customer.objects.filter(followups__sales_rep=request.user),因为后者会产生 N+1 查询(每个客户都查一次跟进记录),在客户量大时页面加载极慢。前者是“反向查询”,效率高。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 App(CRA),不是因为它最先进,而是因为它的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_ROOT,API 请求自然变成同源(/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 读取 csrftoken(Django 默认 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(开发环境 HTTP),CSRF_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] = useState<Customer[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const [statusFilter, setStatusFilter] = useState<string>('all'); useEffect(() => { const fetchCustomers = async () => { try { setLoading(true); const params = statusFilter !== 'all' ? { status: statusFilter } : {}; const response = await api.get<Customer[]>('/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 className="loading">加载中...</div>; if (error) return <div className="error">{error}</div>; return ( <div className="customer-list"> <h2>客户列表</h2> {/* 状态筛选 */} <div className="filter-bar"> <label htmlFor="status-filter">状态:</label> <select id="status-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 className="customer-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.get<Customer[]>('/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_ROOT;3. 将所有其他请求(如/,/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