
简介一款使用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_length200) industry models.CharField(所属行业, max_length100, blankTrue) source models.CharField(客户来源, max_length50, blankTrue) owner models.ForeignKey(User, on_deletemodels.PROTECT, verbose_name负责销售) created_at models.DateTimeField(创建时间, auto_now_addTrue) class Meta: indexes [ models.Index(fields[name, owner]), ] class Contact(models.Model): customer models.ForeignKey(Customer, on_deletemodels.CASCADE, related_namecontacts) name models.CharField(联系人姓名, max_length50) phone models.CharField(手机号, max_length20) position models.CharField(职位, max_length50, blankTrue) class FollowUp(models.Model): customer models.ForeignKey(Customer, on_deletemodels.CASCADE, related_namefollowups) user models.ForeignKey(User, on_deletemodels.PROTECT) content models.TextField(跟进内容) next_follow_date models.DateField(下次跟进日期, nullTrue, blankTrue) created_at models.DateTimeField(auto_now_addTrue)这段代码里最关键的参数是on_deletemodels.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()就会产生 N1 查询问题。客户列表页显示 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( ownerself.request.user ).select_related(owner).prefetch_related( Prefetch(contacts, querysetContact.objects.only(name, phone)), Prefetch(followups, querysetFollowUp.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 contextLoginRequiredMixin确保未登录用户直接跳转到登录页。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, idcustomer_id, ownerrequest.user) if request.method POST: form FollowUpForm(request.POST) if form.is_valid(): followup form.save(commitFalse) 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, pkcustomer.id) else: form FollowUpForm() return render(request, customers/followup_form.html, {form: form, customer: customer})form.save(commitFalse)是先拿到内存对象但不落库这样可以在正式保存前把customer和user这两个字段填进去。update_fields指定只更新last_followup_at这一个字段避免触发整行数据的多余写入。transaction.atomic包裹了两次 save 操作任何一步失败都会回滚。还有一个细节get_object_or_404里带了ownerrequest.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(day1) stats Order.objects.filter( created_at__gtemonth_start, statuspaid ).values(customer__owner__username).annotate( total_amountSum(total_amount), order_countCount(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__gtemonth_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(订单状态, choicesSTATUS_CHOICES, defaultSTATUS_PENDING) total_amount models.DecimalField(订单总额, max_digits10, decimal_places2) created_at models.DateTimeField(auto_now_addTrue) 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_typetext/csv) response[Content-Disposition] attachment; filenameorders.csv writer csv.writer(response) writer.writerow([订单号, 客户, 金额, 状态, 下单时间]) orders Order.objects.select_related(customer).filter( created_at__datetimezone.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 responseget_status_display()是 Django 针对choices字段提供的自动方法能把存储的整数 3 直接映射成“已完成”中文文案。用csv.writer逐行写而不是手动拼接字符串是为了避免字段里出现逗号或引号时 CSV 结构被破坏。很多新手在这个函数里犯的错误是忘记加select_related(customer)导致导出 1000 条订单就要额外执行 1000 次客户表查询导出接口直接超时。路由配置到path(export/, views.export_orders_csv, nameexport_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( statusOrder.STATUS_PAID ).aggregate( totalSum(total_amount), avgAvg(total_amount), countCount(id) )aggregate返回的是一个字典不是 queryset适用于整表统计annotate用于分组统计。这里的statusOrder.STATUS_PAID用了模型常量而不是魔术数字其他人在读这段代码时不用去猜“2”代表什么。如果统计口径里要排除已取消的订单直接在filter里加exclude(statusOrder.STATUS_CANCELLED)不要用 Python 里的if去过滤否则数据库还是查询了全量数据。为了保持 Dashboard 响应速度我通常还会在最外层套一层cache_page(60 * 5)让 5 分钟内的重复访问直接命中缓存。5.2 避免 N1 查询的常用做法select_related 和 prefetch_related 的使用边界列表页、导出功能、Dashboard 的最近交易列表这三处是最容易踩 N1 陷阱的地方。判断原则很简单访问一个对象的字段不会触发额外查询访问对象的外键或反向关联时代码里出现循环就要考虑预取。# 错误示例每个订单都会查一次 customer orders Order.objects.filter(statusOrder.STATUS_PAID) for order in orders: print(order.customer.name) # 正确示例一次 join 把 customer 带出来 orders Order.objects.filter( statusOrder.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.logworkers 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, idorder_id) if not order.can_transition_to(Order.STATUS_APPROVED): return JsonResponse({error: 当前状态不可执行此操作}, status400) 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 21验证备份是否有效的方法也很重要每个月挑一天把最新的备份文件还原到一台临时实例上检查客户数量和订单总额是否与生产库一致。只备份不恢复验证等于没备份。我在这里翻过车连续备份了半个月结果数据库服务器硬件故障还原备份时发现 dump 文件已经损坏最后只能从离线日志里手工恢复。此后我再也没有一次放心过直到每个月都做一次真实还原演练心里才有底。6.4 最后的小提示从运行日志里学会和 Django 对话生产环境里每天必看的是gunicorn_error.log和 Django 的logging输出。我习惯在生产环境把日志级别调到INFO并且给异常堆栈单独记录到独立文件方便下班后排查。另一个小习惯是启动时执行python manage.py check --deploy它会提示你关闭DEBUG、配置ALLOWED_HOSTS、开启安全相关的中间件。跟着它的检查项走一遍相当于免费请了一个安全顾问。Django 的runserver在开发机上随便用但一旦上了生产必须换成 Gunicorn 或者 uWSGI这不是性能洁癖是稳定性的基本要求。这套源码如果直接带着DEBUGTrue上线相当于把自己的数据库连接信息和内网 IP 全暴露给了访问者。希望这套从建模到部署的完整路径能让你少走弯路反正我第一次部署时光是 Nginx 的静态文件配置就折腾了两个晚上。本文还有配套的精品资源点击获取