☰
Django-CRM从零开发实战:模型设计、性能优化与部署
2026/10/7 3:30:59 网站建设 项目流程

简介:一款使用Python高效开发、基于Django框架的CRM客户关系管理系统完整源码,适合需要快速搭建客户管理后台的中高级Python开发者,也适用于参考完整项目进行二次开发的团队或个人。压缩包共652个文件,大小仅10.1MB,内部包含204个Python脚本承担核心业务逻辑,202张JPG与84张PNG图片构成界面素材,67个HTML、16个JavaScript及13个CSS文件实现前端展示,另有YML、Dockerfile、Makefile等环境配置与自动化脚本,整体目录结构清晰,便于定位与修改。资源已有549人学习下载,是一份可直接运行的Django项目范例,能够帮助读者理解MTV架构、ORM数据操作、模板渲染与前端静态资源组织方式;完整源码可视为课程设计或中小型客户管理系统的开发起点,从项目初始化、模型定义到访问控制都可逐步拆解学习,前端样式与响应式布局也一并打包,适合按需替换或扩展功能。无论用于毕业设计、内部工具还是商业项目的前期原型,这套源码都能提供大量可借鉴的实现思路。

1. 从零搭一套 Django-CRM:为什么不用现成框架而要自己写源码

很多团队一提到 CRM,第一反应是去装一套开源的 PHP 系统,或者直接买 SaaS 账号。但在实际做过几个企业级项目之后,我的看法比较直接:如果业务涉及订单审批、客户分群、销售数据看板这类强定制需求,用 Python 和 Django 从零写一套 CRM 源码,反而是后期维护成本最低的方案。Django 自带 ORM、Admin 后台、表单系统和权限框架,一个 5 人以内的开发团队用下班时间就能把核心模块跑起来。这套源码不是给你看热闹的,它能直接解决三个具体问题:客户信息散落在 Excel 和多套系统里没法统一管理;销售跟进记录靠口头汇报无法追溯;以及管理层想要的业绩统计永远要等财务手工导数据。适合谁?适合手里有真实业务、不想被 SaaS 月费绑架、又愿意花两周时间自己改代码的团队。

2. Django-CRM 核心模型设计:把客户、跟进、订单和权限一次建模到位

CRM 系统最容易翻车的地方就是数据模型。很多初学者一上来就建一张巨大的客户表,把所有字段都塞进去,结果后续加一个“客户来源渠道”都要改表结构。我的做法是先把业务对象拆清楚,然后用 Django 的 ORM 把关系理顺。

2.1 从一张客户表拆成五张关联表:建模的取舍逻辑

常见做法是把客户( Customer )、联系人( Contact )、跟进记录( FollowUp )、订单( Order )、订单明细( OrderItem )拆成五个核心模型。客户表和联系人表分离的原因是:一个企业客户下面可能有多个对接人,采购经理和财务总监看的数据不一样;如果只建一张表,每次换对接人都要覆盖原来的记录,历史数据全丢了。订单单独拆出来是为了后续做统计时不干扰客户表。

from django.db import models from django.contrib.auth.models import User class Customer(models.Model): name = models.CharField('客户名称', max_length=200) industry = models.CharField('所属行业', max_length=100, blank=True) source = models.CharField('客户来源', max_length=50, blank=True) owner = models.ForeignKey(User, on_delete=models.PROTECT, verbose_name='负责销售') created_at = models.DateTimeField('创建时间', auto_now_add=True) class Meta: indexes = [ models.Index(fields=['name', 'owner']), ] class Contact(models.Model): customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='contacts') name = models.CharField('联系人姓名', max_length=50) phone = models.CharField('手机号', max_length=20) position = models.CharField('职位', max_length=50, blank=True) class FollowUp(models.Model): customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='followups') user = models.ForeignKey(User, on_delete=models.PROTECT) content = models.TextField('跟进内容') next_follow_date = models.DateField('下次跟进日期', null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True)

这段代码里最关键的参数是on_delete=models.PROTECT。如果客户已经产生了订单,删除客户时 Django 会直接报错,而不是把订单一起级联删掉。很多新手在这里用CASCADE,结果运营误删一个客户,整个订单历史全部没了,这就是典型的没有后悔药。related_name也很重要,它决定了你在查询时是用customer.contacts.all()还是默认的customer.contact_set.all(),显式命名后面写业务逻辑会少踩很多坑。

2.2 用 Django Admin 快速搭建内部管理界面:最小可用后台的配置方法

Django 自带的 Admin 不是摆设,它完全可以作为 CRM 的内部管理后台先顶着用。关键是list_display、list_filter和search_fields这三个参数要配好,否则后台就是个大字报。

from django.contrib import admin from .models import Customer, Contact, FollowUp, Order class ContactInline(admin.TabularInline): model = Contact extra = 0 class FollowUpInline(admin.TabularInline): model = FollowUp extra = 0 @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): list_display = ('name', 'industry', 'source', 'owner', 'created_at') list_filter = ('industry', 'source') search_fields = ('name',) inlines = [ContactInline, FollowUpInline]

配置完成后,在客户的详情页面里可以直接内嵌编辑联系人和跟进记录,不用来回切换页面。这里的extra = 0表示不显示空白的额外表单行,否则一打开详情页就冒出 3 条空的联系人来,运营同事会以为系统坏了。list_filter按行业和来源过滤,是销售主管每天打开后台第一眼要看的东西。这个阶段的交付物已经具备录入和查询能力,下一步是给销售写专用视图。

2.3 从零创建 Django 项目到注册模型:一套顺手的最小命令流

如果是从空文件夹开始建这套源码,我习惯按固定顺序操作,避免后期改配置时迷路。

# 1. 创建虚拟环境并激活(Windows / macOS / Linux 通用) python3 -m venv venv source venv/bin/activate # 2. 安装 Django 和数据库驱动 pip install django psycopg2-binary # 3. 创建项目和应用 django-admin startproject crm_project . python manage.py startapp customers # 4. 把 customers 应用注册进 settings.py 的 INSTALLED_APPS # 然后执行迁移 python manage.py makemigrations customers python manage.py migrate # 5. 创建超级管理员并启动服务 python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000

这里有个非常容易卡住的点:django-admin startproject crm_project .后面的那个点不能丢,它表示在当前目录生成 manage.py,而不是再套一层子目录。数据库我一般直接用 PostgreSQL,因为 CRM 后面要跑报表,MySQL 在复杂分组查询上性能差一些。迁移这一步如果makemigrations提示No changes detected,多半是应用没有注册进INSTALLED_APPS,检查拼写而不是重复执行命令。这套命令流跑完,Django 自带的后台已经可以在浏览器里访问了。

3. 客户管理模块的视图与表单:从 MVC 到用户真实操作路径

Admin 后台适合内部少量人员操作,但销售团队真正要用的界面应该是:打开一个页面,看到自己名下所有客户,点进去能写跟进、下订单。这部分要自己写视图和模板,Django 的通用视图能省掉大量重复代码。

3.1 用 ListView 和 DetailView 搭建客户列表与详情页:查询性能与权限控制

我用ListView展示客户列表,配合django-filter做多维筛选。这里有个性能玄学:一旦模板里访问了customer.contacts.all()或customer.followups.all(),就会产生 N+1 查询问题。客户列表页显示 50 条记录,数据库就要执行 51 次查询,页面响应直接到 1 秒以上。

from django.views.generic import ListView, DetailView from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Prefetch from .models import Customer, Contact, FollowUp class CustomerListView(LoginRequiredMixin, ListView): model = Customer template_name = 'customers/customer_list.html' context_object_name = 'customers' paginate_by = 20 def get_queryset(self): return Customer.objects.filter( owner=self.request.user ).select_related('owner').prefetch_related( Prefetch('contacts', queryset=Contact.objects.only('name', 'phone')), Prefetch('followups', queryset=FollowUp.objects.order_by('-created_at')[:3]) ) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['total_customers'] = self.get_queryset().count() return context

LoginRequiredMixin确保未登录用户直接跳转到登录页。select_related解决的是外键查询,prefetch_related解决的是反向关联查询。联系人和跟进记录各自只取需要的字段,跟进记录只拿最近三条——列表页不需要把半年的跟进历史全部拖出来。很多拿到源码的人第一件事就是删掉这些优化,觉得代码啰嗦,结果上线两周后列表页越来越慢,这就是典型的没吃过亏。

3.2 表单验证与跟进记录写入:ModelForm 和事务处理的正确打开方式

写跟进记录是 CRM 里被点击次数最多的操作,表单要做两件事:保存内容,同时更新客户表上的“最近跟进时间”字段。这两个操作必须放在同一个数据库事务里,否则会出现跟进记录写进去了、但客户列表上的时间没更新的数据不一致问题。

from django.shortcuts import render, redirect, get_object_or_404 from django.db import transaction from django.contrib.auth.decorators import login_required from .forms import FollowUpForm @login_required @transaction.atomic def add_followup(request, customer_id): customer = get_object_or_404(Customer, id=customer_id, owner=request.user) if request.method == 'POST': form = FollowUpForm(request.POST) if form.is_valid(): followup = form.save(commit=False) followup.customer = customer followup.user = request.user followup.save() customer.last_followup_at = followup.created_at customer.save(update_fields=['last_followup_at']) return redirect('customer_detail', pk=customer.id) else: form = FollowUpForm() return render(request, 'customers/followup_form.html', {'form': form, 'customer': customer})

form.save(commit=False)是先拿到内存对象但不落库,这样可以在正式保存前把customer和user这两个字段填进去。update_fields指定只更新last_followup_at这一个字段,避免触发整行数据的多余写入。transaction.atomic包裹了两次 save 操作,任何一步失败都会回滚。还有一个细节:get_object_or_404里带了owner=request.user,这样销售只能看到自己的客户,别人就算知道 URL 也改不了——权限不是靠隐藏按钮实现的。

3.3 Django 的 queryset 如何应对销售排行榜:按人分组统计并格式化输出

销售主管最关心的“本月每个人的成单金额”,只需要一行 queryset 加一个循环就能算出来。

from django.db.models import Sum, Count from django.utils import timezone from datetime import timedelta month_start = timezone.now().replace(day=1) stats = Order.objects.filter( created_at__gte=month_start, status='paid' ).values('customer__owner__username').annotate( total_amount=Sum('total_amount'), order_count=Count('id') ).order_by('-total_amount') for row in stats: print(f"销售 {row['customer__owner__username']} 本月成单 {row['order_count']} 笔," f"总额 {row['total_amount']}")

values('customer__owner__username')会把结果集按销售用户名分组,annotate同时算出总额和单量。created_at__gte=month_start是日期过滤的惯用写法,比先取datetime再比较要简洁得多。这个统计逻辑在列表页渲染时直接传给模板即可。这套源码里我用同样的模式做了客户来源分布饼图的数据接口,销售主管在 Dashboard 上能看到实时数据,不用再等财务月底发 Excel。

4. 订单与产品模块:从报价到回款的完整闭环

客户管理只是把信息管住了,真正的业务流转发生在订单里。这个模块的难点不在增删改查,而在于状态变化。一个订单从“待审核”到“已付款”再到“已完成”,每一步都牵扯到库存和财务,状态机设计不好,后面全是坑。

4.1 订单状态机的设计:用整数常量代替字符串硬编码

我见过最离谱的代码是直接在视图里写if order.status == 'yifukuan',全拼、拼音、英文混着来,改一个状态名要把整个项目搜一遍。正确做法是在模型里定义状态常量。

class Order(models.Model): STATUS_PENDING = 0 STATUS_APPROVED = 1 STATUS_PAID = 2 STATUS_COMPLETED = 3 STATUS_CANCELLED = 4 STATUS_CHOICES = [ (STATUS_PENDING, '待审核'), (STATUS_APPROVED, '已通过'), (STATUS_PAID, '已付款'), (STATUS_COMPLETED, '已完成'), (STATUS_CANCELLED, '已取消'), ] status = models.IntegerField('订单状态', choices=STATUS_CHOICES, default=STATUS_PENDING) total_amount = models.DecimalField('订单总额', max_digits=10, decimal_places=2) created_at = models.DateTimeField(auto_now_add=True) def can_transition_to(self, new_status): allowed = { self.STATUS_PENDING: [self.STATUS_APPROVED, self.STATUS_CANCELLED], self.STATUS_APPROVED: [self.STATUS_PAID, self.STATUS_CANCELLED], } return new_status in allowed.get(self.status, [])

用IntegerField而不是CharField的原因很简单:数据库存储空间更小,排序更直接,而且不会出现“已付款”和“已付kuan”这种低级的脏数据。can_transition_to是状态流转的唯一入口,所有修改状态的视图都必须调用它。这个设计在业务上有一个直接好处:运维人员误操作把已付款订单直接改回待审核时,系统会抛出异常而不是静默接受。

4.2 订单明细的 inline 编辑:在 Django Admin 里一次录入多行商品

订单表本身不存商品名称和单价,而是通过订单明细表(OrderItem)存多行数据。在 Django Admin 里用TabularInline可以让运营在一个页面里同时录入订单头和多行商品明细。

class OrderItemInline(admin.TabularInline): model = OrderItem extra = 1 fields = ('product_name', 'quantity', 'unit_price', 'subtotal') readonly_fields = ('subtotal',) @admin.register(Order) class OrderAdmin(admin.ModelAdmin): list_display = ('id', 'customer', 'status', 'total_amount', 'created_at') list_filter = ('status',) inlines = [OrderItemInline]

subtotal字段由unit_price * quantity计算得出,在模型里用@property定义,Admin 里设为只读,避免手工录入导致总金额对不上。extra = 1表示默认显示一行空表单,运营不用每次点“添加另一行”。这块的潜在坑是:list_filter如果用了状态字段,Admin 左侧会出现一组单选按钮,点击某个状态后列表只显示对应状态的订单——这个体验对日常核对回款非常重要,建议保留不要动。

4.3 订单列表页的筛选与导出 CSV:销售日报的最后一公里

销售每天下班前要导一份今天的订单明细发到群里,这个功能如果没做,他们就会去数据库里手动查。

import csv from django.http import HttpResponse @login_required def export_orders_csv(request): response = HttpResponse(content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="orders.csv"' writer = csv.writer(response) writer.writerow(['订单号', '客户', '金额', '状态', '下单时间']) orders = Order.objects.select_related('customer').filter( created_at__date=timezone.localdate() ) for order in orders: writer.writerow([ order.id, order.customer.name, order.total_amount, order.get_status_display(), order.created_at.strftime('%Y-%m-%d %H:%M') ]) return response

get_status_display()是 Django 针对choices字段提供的自动方法,能把存储的整数 3 直接映射成“已完成”中文文案。用csv.writer逐行写而不是手动拼接字符串,是为了避免字段里出现逗号或引号时 CSV 结构被破坏。很多新手在这个函数里犯的错误是忘记加select_related('customer'),导致导出 1000 条订单就要额外执行 1000 次客户表查询,导出接口直接超时。路由配置到path('export/', views.export_orders_csv, name='export_orders')以后,销售只要在浏览器地址栏输入这个 URL 就能下载当日数据。

5. 统计看板与性能优化:让查询从秒级降到毫秒级的 3 个关键改造

CRM 的价值一半在录入,一半在统计输出。但统计页面往往是最容易拖垮数据库的。一个 Dashboard 上同时挂“本月销售额”“客户增长趋势”“销售排行榜”三张图表,如果都用最原始的 ORM 查询,数据库会被打爆。这一章的 3 个改造做完,统计接口的响应时间能明显降下来。

5.1 用聚合查询替代 Python 循环统计:annotate 和 aggregate 的正确分工

常见错误是先把所有订单查出来,然后在 Python 里循环累加金额。订单量少的时候没感觉,到月结时几十万条数据灌进来,页面直接超时。正确做法是把计算压给数据库完成。

from django.db.models import Sum, Count, Avg total_revenue = Order.objects.filter( status=Order.STATUS_PAID ).aggregate( total=Sum('total_amount'), avg=Avg('total_amount'), count=Count('id') )

aggregate返回的是一个字典,不是 queryset,适用于整表统计;annotate用于分组统计。这里的status=Order.STATUS_PAID用了模型常量而不是魔术数字,其他人在读这段代码时不用去猜“2”代表什么。如果统计口径里要排除已取消的订单,直接在filter里加exclude(status=Order.STATUS_CANCELLED),不要用 Python 里的if去过滤,否则数据库还是查询了全量数据。为了保持 Dashboard 响应速度,我通常还会在最外层套一层cache_page(60 * 5),让 5 分钟内的重复访问直接命中缓存。

5.2 避免 N+1 查询的常用做法:select_related 和 prefetch_related 的使用边界

列表页、导出功能、Dashboard 的最近交易列表,这三处是最容易踩 N+1 陷阱的地方。判断原则很简单:访问一个对象的字段不会触发额外查询;访问对象的外键或反向关联时,代码里出现循环,就要考虑预取。

# 错误示例:每个订单都会查一次 customer orders = Order.objects.filter(status=Order.STATUS_PAID) for order in orders: print(order.customer.name) # 正确示例:一次 join 把 customer 带出来 orders = Order.objects.filter( status=Order.STATUS_PAID ).select_related('customer') for order in orders: print(order.customer.name)

select_related用于一对一和外键,它通过 SQL 的 JOIN 把关联对象的数据一次性查出来;prefetch_related用于多对多和反向关联,它是先查主表再查关联表,然后在 Python 内存里做匹配。两者的边界不要搞混,否则查询语句会报错或者产生更大的性能浪费。调试时在connection.queries里看 SQL 条数是最直观的方法,目标是一个请求页面里的 SQL 数量稳定在个位数。

5.3 慢查询定位与数据库索引补充:用 Django Debug Toolbar 抓出真正的元凶

拿到了源码不要急着部署,先装上django-debug-toolbar,把页面打开一遍,看左侧的 SQL 面板。它能告诉你每一条 SQL 的执行时间和重复次数,一眼就能看出哪张表缺索引。

# settings.py 中的配置片段 INSTALLED_APPS = [ # ... 'debug_toolbar', ] MIDDLEWARE = [ # ... 'debug_toolbar.middleware.DebugToolbarMiddleware', ] INTERNAL_IPS = ['127.0.0.1']

INTERNAL_IPS必须配置成自己的回环地址,否则工具条不会显示。补索引的操作在模型 Meta 里做,执行makemigrations和migrate即可:

class Order(models.Model): # 已有字段... class Meta: indexes = [ models.Index(fields=['status', 'created_at']), ]

这个联合索引直接服务于 Dashboard 上最常见的过滤条件组合“已付款 + 本月”。很多团队在生产环境慢查询严重,排查半天发现是漏了这种最简单的联合索引。补充索引后一定要看EXPLAIN,确认查询走的是 index scan 而不是 seq scan。具体方法是在数据库客户端执行EXPLAIN SELECT ...,如果看到Seq Scan就说明索引没生效,最常见的坑是查询条件里对索引字段做了函数转换,比如DATE(created_at)。

6. 部署实战与权限控制边界:把源码安全地跑在生产环境

源码在本地跑通只是第一步,真正的问题出在部署之后:Django 默认的runserver完全不适用于生产环境,静态文件、数据库备份、用户权限都需要重新处理。这一章不讲 Docker 和 Kubernetes,只聚焦中小团队最常用的一台云服务器 + Nginx + Gunicorn 方案。

提示:生产环境部署前,务必执行python manage.py check --deploy,它会列出一堆安全警告,逐条处理。

6.1 Nginx 反代与 Gunicorn 配置:一套稳跑的部署脚本

# 安装依赖 pip install gunicorn # 启动 Gunicorn,监听本机 8001 端口 gunicorn crm_project.wsgi:application \ --bind 127.0.0.1:8001 \ --workers 3 \ --timeout 60 \ --error-logfile /var/log/crm/gunicorn_error.log

workers = 3通常是一个合理的起点,堆太多反而因为 GIL 和内存争抢引起性能下降。Nginx 配置里要特别处理两个目录:/static/和/media/。Static 用alias指向 Django 的collectstatic输出目录,Media 指向用户上传的文件目录。这两个目录如果交给 Django 处理,文件响应速度会慢一个数量级,而且会阻塞业务线程。

server { listen 80; server_name your-domain.com; location /static/ { alias /home/ubuntu/crm_project/static/; } location /media/ { alias /home/ubuntu/crm_project/media/; } location / { proxy_pass http://127.0.0.1:8001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

6.2 权限继承与按钮级控制:Django 内置权限不够用时怎么补

Django 自带的权限模型只能控制到“能不能访问订单模块”,控制不了“能不能审核订单”。对于 CRM 这种角色分明的系统,需要引入外部的权限应用,比如django-guardian,它提供对象级权限,可以做到让销售 A 看不到销售 B 名下的客户。在视图层配合装饰器做双重校验:

from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_POST from django.core.exceptions import PermissionDenied @login_required @require_POST def approve_order(request, order_id): if not request.user.has_perm('crm.can_approve_order'): raise PermissionDenied order = get_object_or_404(Order, id=order_id) if not order.can_transition_to(Order.STATUS_APPROVED): return JsonResponse({'error': '当前状态不可执行此操作'}, status=400) order.status = Order.STATUS_APPROVED order.save(update_fields=['status']) return JsonResponse({'ok': True})

require_POST限制只能用 POST 请求修改状态,防止浏览器预取或爬虫把订单状态改掉。has_perm检查的是全局权限,如果要细分到“只能审核自己团队创建的订单”,那就得在模板和视图两层同时判断。我的一个习惯是:凡是修改类操作,一律用 POST + 装饰器 + 显式权限三重保护,不给任何裸奔的 GET 修改接口留余地。

6.3 数据库备份与迁移策略:防止自己和数据一起翻车

CRM 数据一旦丢失,没有任何后悔药,数据库备份是最不可跳过的一步。我用的是一个简单的 cron 定时任务,每天凌晨 3 点导出一次 PostgreSQL 数据,同时保留最近 30 天的历史备份。

#!/bin/bash BACKUP_DIR=/backup/crm DATE=$(date +\%Y\%m\%d\%H\%M) pg_dump -U crm_user -h localhost -F c -f "$BACKUP_DIR/crm_$DATE.dump" crm_db find "$BACKUP_DIR" -name "*.dump" -mtime +30 -delete

在 crontab 里注册执行即可实现无人值守备份:

0 3 * * * /home/ubuntu/scripts/backup_crm.sh >> /var/log/backup_crm.log 2>&1

验证备份是否有效的方法也很重要:每个月挑一天,把最新的备份文件还原到一台临时实例上,检查客户数量和订单总额是否与生产库一致。只备份不恢复验证,等于没备份。我在这里翻过车:连续备份了半个月,结果数据库服务器硬件故障,还原备份时发现 dump 文件已经损坏,最后只能从离线日志里手工恢复。此后我再也没有一次放心过,直到每个月都做一次真实还原演练,心里才有底。

6.4 最后的小提示:从运行日志里学会和 Django 对话

生产环境里每天必看的是gunicorn_error.log和 Django 的logging输出。我习惯在生产环境把日志级别调到INFO,并且给异常堆栈单独记录到独立文件,方便下班后排查。另一个小习惯是启动时执行python manage.py check --deploy,它会提示你关闭DEBUG、配置ALLOWED_HOSTS、开启安全相关的中间件。跟着它的检查项走一遍,相当于免费请了一个安全顾问。Django 的runserver在开发机上随便用,但一旦上了生产,必须换成 Gunicorn 或者 uWSGI,这不是性能洁癖,是稳定性的基本要求。这套源码如果直接带着DEBUG=True上线,相当于把自己的数据库连接信息和内网 IP 全暴露给了访问者。希望这套从建模到部署的完整路径能让你少走弯路,反正我第一次部署时,光是 Nginx 的静态文件配置就折腾了两个晚上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询