简介:这是一套采用高效Python开发、基于Django框架的CRM客户关系管理系统完整源码,面向需要搭建或学习Web业务系统的开发者、运维及项目管理人员,可用于客户管理、销售进程跟踪、数据统计等场景。压缩包共652个文件、10.1MB,主要包含Python脚本、HTML页面、JavaScript与CSS样式、Shell与Dockerfile部署脚本、YAML配置以及RST文档等,完整覆盖后端逻辑、前端交互、样式布局与部署配置,便于按模块阅读与二次开发。资源中还有大量JPG与PNG图片素材,说明系统具备较完备的界面与功能。已有550人学习下载,适合希望理解Django项目分层结构、实际业务功能实现,或在现有基础上扩展定制的Python开发者。
1. 为什么说 Django-CRM 完整源码的价值全在数据模型上
用 Python + Django 写一套 CRM 完整源码,真正拉开差距的从来不是页面漂不漂亮,而是数据模型怎么设计、权限怎么下沉、查询怎么不把数据库打爆。我见过太多团队从零自建客户管理系统,三个月后集体死在三个坎上:字段越加越多逻辑开始绕、列表页越跑越慢销售开始骂、销售和运营对不上数互相甩锅。这套 Django-CRM 完整源码恰恰把这三件事做了前置设计,拿来改一改能直接扛住几十个销售同时录客户、写跟进、看漏斗。它适合两类人:一类是公司内部要快速交付 CRM 的开发者,想少走弯路;另一类是已经写过 Django 增删改查、想看看企业级项目如何组织模型与权限的中级工程师。下面我从数据层开始,把这套源码里真正值得抄的决策一个个拆开讲。
2. 拆解 Django-CRM 的数据层:五张核心表决定了整站上限
CRM 这类业务系统,页面能换肤、按钮能调,但表结构一旦定错,后面每次加功能都是在漏水的船上打补丁。一套完整的 Django-CRM 源码,核心通常落在客户、联系人、商机、跟进记录、员工账号这五类实体上。
2.1 客户与联系人为什么必须拆成两张表
很多新手写 CRM 最常犯的错,是把联系人的手机号直接做成客户表里的字段。看起来省了一张表,实际上把公司的“关系管理”做成了“通讯录管理”。客户是一个组织实体,有行业、规模、地域属性;联系人是客户下面的具体的人,有职位、微信、手机号。一个客户可以有多位联系人,一个联系人只属于一个客户,这是典型的一对多关系。拆成两张表之后,后续做“按联系人群发短信”“离职员工客户交接”“统计某客户有多少个决策人”才都有地方落脚。
from django.db import models class Client(models.Model): name = models.CharField(max_length=128, db_index=True) industry = models.CharField(max_length=64, blank=True) source = models.CharField(max_length=32, default='manual') is_deleted = models.BooleanField(default=False, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: indexes = [ models.Index(fields=['source', 'is_deleted']), ] class Contact(models.Model): client = models.ForeignKey(Client, on_delete=models.PROTECT, related_name='contacts') name = models.CharField(max_length=64) position = models.CharField(max_length=64, blank=True) mobile = models.CharField(max_length=32, blank=True) is_primary = models.BooleanField(default=False)这段模型有两个容易被忽略的细节。on_delete=models.PROTECT是为了防止误删客户时把联系人连带删掉,CRM 里客户数据是资产,宁可让它删不动,也不能静默丢数据。db_index=True加在name和is_deleted上,是因为客户列表页的搜索和过滤最终都会落在 WHERE 条件上,不加索引的话,数据量到几万条之后,列表页就会从秒开变成转圈。
联系人表里的is_primary是一个“主联系人”标记,业务上非常有用。给客户发合同、对账、催款时,系统默认取主联系人,而不是让销售每次手动挑人。很多团队把这个业务规则写在视图层,每次查询都.filter(is_primary=True).first(),结果前端多处逻辑不一致。写在模型字段上,配合 Article 保存逻辑强制检查,才是正路。
2.2 商机与跟进记录:状态机驱动的业务流转
客户进来了,但不等于马上签单。中间还要经过需求识别、方案报价、商务谈判、赢单或输单这些阶段,这就是商机表存在的意义。商机表挂在客户下面,一条客户可以有多个商机,因为同一个客户可能同时谈着不同的产品线。
跟进记录是另一张关键表,每一条记录代表销售和客户的一次实质互动,包括电话、拜访、微信沟通、邮件往来。它和商机之间是时间线的关系——销售每做一次动作,就往里面追加一条记录,商机的status和follow_count跟着变。Django 里最稳妥的追加写法是「先更新父表,再插入记录,全程包在事务里」。
from django.db import transaction from django.db.models import F opportunity = Opportunity.objects.select_for_update().get(pk=opp_id) opportunity.status = new_status opportunity.follow_count = F('follow_count') + 1 opportunity.save() FollowUp.objects.create( opportunity=opportunity, content=content, contact=contact, creator=request.user, )这里有三层细节值得说明。第一,外层包transaction.atomic(),保证“改状态 + 插入记录”要么都成功,要么都回滚,否则会出现跟进记录写了、商机状态没更新这种数据脏块。第二,select_for_update()是给当前行加锁,拦住两个销售同时给同一个商机录跟进时的并发覆盖。第三,follow_count = F('follow_count') + 1让累加操作在数据库端完成,而不是先把值读出来加 1 再写回去,这一步直接消灭了并发下计数丢更新的大坑。这段代码是这套 CRM 源码里最值得背下来的片段之一。
2.3 CRUD 之外:软删除与审计字段的建模细节
业务型系统里,删除永远比新增危险。销售手滑删掉一个客户,运营下个月做复盘时数据就少了一块。所以完整源码里的核心表几乎不会做物理删除,而是用is_deleted布尔字段做软删。查询时统一过滤掉已删除数据,但底层数据永远留在库里。
除了软删除,每张业务表还需要带上创建时间、更新时间、创建人这三个审计字段。这三件套的作用,在出问题复盘时才会体现出来:销售说“我上周就录了这个客户”,一查created_at发现是昨天;运营说“这批数据是团队导入的”,一查creator发现是某个销售个人行为。Django 里可以封装一个基类统一加这些字段,省得每张表都重复写。
class BaseModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) creator = models.ForeignKey('auth.User', null=True, on_delete=models.SET_NULL) class Meta: abstract = Trueauto_now_add是插入时自动取当前时间,auto_now是每次save()都刷新时间。注意auto_now在update()批量更新时不会生效,如果你用了Opportunity.objects.filter(...).update(status='won'),updated_at不会自动变,需要手动带上。这算是 Django ORM 的一个小暗坑,后面避坑章节还会展开讲。
3. 把 CRM 功能模块落到 Django 视图中:跟进、商机与权限
数据模型定了之后,接下来是视图层。一套合格的企业级 CRM,视图绝不只是 CRUD,它要回答三个问题:一次互动怎么记录、数据怎么汇总成看板、不同角色能看哪些数据。
3.1 跟进记录的追加逻辑:POST 与事务的写法
跟进记录的写入接口是 CRM 里调用频率最高的接口之一。一个销售每天新增几十条跟进,如果写入逻辑里有隐藏的性能问题,数据库会最先扛不住。
@login_required @require_POST def add_followup(request, opp_id): form = FollowUpForm(request.POST) if not form.is_valid(): return JsonResponse({'code': 1, 'msg': '参数有误'}, status=400) with transaction.atomic(): opp = Opportunity.objects.select_for_update().get(pk=opp_id, is_deleted=False) opp.status = form.cleaned_data['new_status'] or opp.status opp.follow_count = F('follow_count') + 1 opp.save() FollowUp.objects.create( opportunity=opp, content=form.cleaned_data['content'], contact=form.cleaned_data.get('contact'), creator=request.user, ) return JsonResponse({'code': 0, 'msg': 'ok'})这段代码把上一章的模型设计落到了接口层。@require_POST强制写入接口只接受 POST,避免有人用 GET 触发状态变更。select_for_update锁行、F表达式做计数累加,这两个组合拳在并发场景下保证数据不错乱。有个细节:表单校验放在事务外,校验失败直接返回,不进数据库,省掉无谓的连接占用。
表单实例化时要注意,不要把数据库查询写进表单的__init__或字段的queryset里太频繁。比如联系人下拉框,每个销售可见的联系人不同,可以在 form 里接收request.user,在clean()阶段做权限校验,而不是直接Contact.objects.all()。
3.2 销售漏斗看板:用 Q 对象做多条件聚合
老板和销售主管打开 CRM,第一件事是看漏斗——各阶段的商机数量和金额合计数。如果这个页面用循环逐条统计,数据量一大就卡死。正解是让数据库一次性聚合出结果。
from django.db.models import Count, Sum, Q funnel = ( Opportunity.objects .filter(owner=request.user, is_deleted=False) .values('status') .annotate( cnt=Count('id'), total_amount=Sum('amount', filter=Q(is_deleted=False)), ) .order_by('status') )这段 SQL 翻译成数据库动作就是一次GROUP BY status,同时算出每个状态的商机数和金额合计。注意Sum('amount', filter=Q(...))是 Django 2.0 之后支持的条件聚合写法,比先filter再聚合要灵活,尤其适合“金额只看未删除商机,但数量统计要包含已删除”这类混合口径。order_by('status')保证前端拿到的是状态顺序固定的列表,不然每次返回顺序不一致,图表会跳。
实际写看板时建议再加两个参数:时间范围(如近 30 天)和过滤维度(按团队、按个人)。把这两个维度加进filter(),而不是聚合完在 Python 里筛,否则白费了数据库的索引。
3.3 数据权限:不靠前端藏着,靠 ORM 层过滤
CRM 里的权限问题比普通博客系统复杂得多。普通系统只需要区分“登录/未登录”,CRM 必须区分“这个客户是谁的”。一位销售不应该看到另一位销售的客户,主管可以看到下属的全部客户,管理员可以看到所有数据。这套层级权限如果只在前端做“菜单隐藏”,等于没做——接口被人摸到就能看到全部数据。
class ClientQuerySet(models.QuerySet): def visible_to(self, user): if user.is_superuser: return self if hasattr(user, 'manager_profile'): subordinate_ids = user.manager_profile.get_subordinate_ids() return self.filter(owner_id__in=subordinate_ids, is_deleted=False) return self.filter(owner=user, is_deleted=False) class ClientManager(models.Manager): def get_queryset(self): return ClientQuerySet(self.model, using=self._db).filter(is_deleted=False)把权限下沉到 QuerySet 层,是最适合 Django CRM 的做法。视图里只需要Client.objects.visible_to(request.user),每个入口自然带上权限过滤,不会出现漏网之鱼。visible_to内部先判断角色:超管直接放行,主管先查出下属员工 ID 列表再过滤,普通销售只看自己的数据。
这里最大的工程意义在于:团队后面每加一个视图,只要约定好用这个 QuerySet,就不会再犯“忘记过滤数据范围”的低级错误。权限校验是安全底线,宁可写在少数几个统一入口,也不要散落在几十个视图函数里各自实现。
4. 让 CRM 真正「高效」起来的 4 处关键优化
标题里的“高效”不是形容词,是硬指标。CRM 列表页的响应时间、看板聚合的查询次数、批量导入是否卡死,这些都是用户每天摸得到的东西。下面按优先级从高到低列四个必做优化。
4.1 N+1 查询是列表页卡顿的元凶:select_related 与 prefetch_related 怎么选
客户列表页除了显示客户名和行业,通常还要显示“有几个联系人”“最近一次跟进时间”。新手代码会写成循环里逐条查询,这就是经典的 N+1 问题。
# 反例:循环中查数据库,30 条客户触发 90+ 条 SQL clients = Client.objects.filter(is_deleted=False)[:30] for client in clients: client.contacts.count() client.opportunities.first() # 正解:一条 SQL 联合查出联系人,一条 SQL 查出商机 from django.db.models import Prefetch clients = ( Client.objects .filter(is_deleted=False) .prefetch_related( Prefetch('contacts', queryset=Contact.objects.filter(is_deleted=False)), Prefetch('opportunities', queryset=Opportunity.objects.filter(is_deleted=False).order_by('-updated_at')), )[:30] )select_related和prefetch_related的区别很多人分不清:前者用于外键的单对象关联,生成JOIN查询,适合client.creator这种多对一;后者用于一对多或多对多,先查主表再查关联表,在内存里做组合,适合client.contacts这种。列表页带联系人数量属于一对多,必须用prefetch_related。
用Prefetch对象还能定制子查询,比如只取未删除的联系人、商机按更新时间倒序取最新一条。这个细节很重要,否则prefetch_related会把全部联系人加载进来——客户 100 个联系人时,内存直接爆掉。
4.2 热点页面缓存:销售漏斗看板不该每次现算
漏斗看板是 CRM 里访问频率极高、数据变化频率极低的页面。销售主管一天刷新几十次,每次刷新都让数据库重跑一次GROUP BY,非常浪费。对这种页面,常见做法是用 Redis 缓存结果,设一个较短的 TTL(比如 30 秒)。
from django.core.cache import cache import json def funnel_api(request): cache_key = f'crm:funnel:{request.user.id}' payload = cache.get(cache_key) if payload: return JsonResponse(json.loads(payload)) data = build_funnel_data(request.user) cache.set(cache_key, json.dumps(data), timeout=30) return JsonResponse(data)关键点在缓存键设计里:一定要带上request.user.id。因为不同销售权限不同,漏斗里的商机列表不同,共用同一个缓存键会造成数据串账。严格说,带上了负责人 ID 还会把同一条 SQL 的执行结果在不同人之间重复缓存,但 CRM 场景里漏斗本身就该按人隔离,所以这个键设计是对的。TTL 设 30 秒而不是永久有效,是为了配合新增跟进的操作——刚录完一条跟进,主管最多等 30 秒就能看到最新数据,体验上完全可接受。
这种「缓存键 + TTL 兜底」的方案比重建一套缓存失效机制要简单得多,也是实战里最不容易出 bug 的缓存写法。等到 TTL 不够用、需要「一改就立即失效」时,再考虑显式删除缓存键也不迟。
4.3 批量导入与邮件提醒:交给异步任务队列
CRM 里一定有“批量导入客户 Excel”和“给销售发跟进提醒邮件”这类操作。同步执行时,导入 5000 条客户要跑几十秒,浏览器一直转圈,用户以为系统死了。异步任务队列是标准解法。
# tasks.py from celery import shared_task @shared_task def import_clients_batch(uploaded_file_path, uploader_id): with open(uploaded_file_path, 'rb') as f: rows = parse_excel(f) for row in rows: client, created = Client.objects.get_or_create( name=row['name'], defaults={'industry': row.get('industry', ''), 'creator_id': uploader_id}, )shared_task的写法让任务函数不依赖具体项目配置,直接放到任意 Django App 里都能被 worker 发现。get_or_create在这里解决了“重复导入同一家客户”的问题:已存在的客户直接跳过,只用created标记判断是否新增。任务完成后通过回调通知上传者,比如把结果数写进 Redis,前端轮询查询。
使用异步任务的边界要记住:不是所有操作都值得异步。单条记录的增删改查、漏斗看板聚合这种毫秒级操作,走异步反而增加复杂度。异步任务适合耗时超过 1 秒的 IO 密集操作,典型场景就是 Excel 导入、邮件群发、短信通知、报表导出。
4.4 分页列表瘦身:用 values 替代完整实体序列化
CRM 列表页往往只需要展示几列:客户名、行业、负责人、最近跟进时间。但 DRF(Django REST Framework)默认序列化器会输出模型的所有字段,包括备注、创建人、更新时间等重字段。数据量大时,序列化时间可能比查询时间还长。
# 反例:序列化完整模型,30 个字段全输出 # 正例:只取列表页需要的 5 个字段 clients = ( Client.objects .filter(is_deleted=False) .values('id', 'name', 'industry', 'owner__username', 'updated_at') .annotate(last_followup=Max('followups__created_at')) .order_by('-updated_at')[:50] )values()指定查询列之后,ORM 生成的 SQL 会减少选择的列,配合annotate在数据库端算出最近跟进时间,得到的就是可以直接丢给前端渲染的轻量字典集合。列表页和详情页的需求天然不同,列表页要“快而瘦”,详情页要“全而准”,没必要让同一个序列化器两头兼顾。
这里有个取舍要提醒:values()拿出来的是普通字典,失去了 ORM 对象的方法和属性。如果列表页还要用模型里的自定义属性,建议在values()基础上补充计算,而不是为了保留模型方法放弃这个优化。
5. Django-CRM 上线踩坑实录:5 个必须提前处理的坑
代码写得再顺,部署上线时该踩的坑一个也躲不掉。下面 5 条是我在模拟项目 X 和某公司内部 CRM 开发中反复遇到的真实问题,按“现象 → 原因 → 解决”的格式整理,每条都值得你在动手前先看一遍。
5.1 软删除过滤器和 Django Admin 不兼容
现象:重写了自定义 Manager 的get_queryset过滤掉is_deleted=True的数据后,Django Admin 里编辑客户时,关联的联系人下拉框里仍然出现已删除的旧联系人,而且编辑保存后,已删除的客户又“复活”了。
原因:Django Admin 的关系字段默认使用关联模型的_base_manager,而不是自定义的objects管理器。_base_manager不做行级过滤,所以无论你的get_queryset怎么写,Admin 的关联查询都不会自动带is_deleted=False条件。
解决:自定义 Manager 时,把过滤逻辑放在自定义 QuerySet 上,并在模型里显式声明base_manager_name:
class Client(models.Model): objects = ClientManager() base_manager_name = 'objects'或者在 Admin 的formfield_for_foreignkey方法里手动过滤。这两种方案选一个就行,我推荐第二种,因为base_manager_name改了之后,某些依赖原始链路的场景(比如反向 FK 查询)可能受影响。前者是全局生效,后者是只影响 Admin 页,影响面更可控。
5.2 datetime.now() 导致「今日新增客户」统计翻车
现象:运营反馈“今日新增客户数”显示为 0,但列表里肉眼可见今天有销售录入了客户。更诡异的是,过了中午 12 点数字反而变正常。
原因:项目配置了USE_TZ=True,数据库里存的created_at是 UTC 时间。而视图里写的datetime.now()返回的是本地时间的 naive datetime,直接拿它去过滤数据库字段,会错开时区无法匹配。
解决:统一使用 Django 的timezone.now(),并显式构造当日零点:
from django.utils import timezone from datetime import timedelta now = timezone.now() today_start = timezone.localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) today_start = timezone.make_aware(today_start) count = Client.objects.filter(is_deleted=False, created_at__gte=today_start).count()timezone.localtime()先转成本地时间,再replace掉时分支零点,最后用make_aware重新附上时区信息。这套操作保证查询条件和数据库存储都是 aware datetime,不会出现隐式转换导致的错位。
5.3 并发录跟进导致 follow_count 丢更新
现象:两位销售同时给同一个商机录跟进,后台显示跟进记录有两条,但商机列表里的“跟进次数”只加了 1,少算了一次。
原因:代码先get出商机对象,读出follow_count值,Python 层加 1 再save()写回。并发时两条请求同时读到旧值(比如 3),各自加 1 都写成 4,正确结果是 5。
解决:用第 2 章里展示的F表达式配合select_for_update()。F('follow_count') + 1让数据库端完成累加,行锁保证同时只有一条请求能修改该行。如果不想用行锁,也可以用update(follow_count=F('follow_count') + 1)做原子更新,同样能解决丢更新问题。行锁适合后面还要继续对该行做其他操作的情况,原子更新适合“只改这一个字段”的纯净场景。
5.4 自定义 Manager 被序列化后丢掉了过滤
现象:导出客户 JSON 后,导入到另一套环境,已删除的客户数据全部出现,和原环境行为不一致。
原因:Django 的序列化产物只包含模型字段,不包含自定义 Manager 的过滤逻辑,导入时直接落库绕过is_deleted过滤。
解决:序列化数据库对象时,显式用Client.objects.all()而不是默认objects。这里的关键认知是:objects管理器已经带了is_deleted=False过滤,序列化出的结果自然不含已删除数据。但导入端如果只是普通objects.create(),写入的字段完全可控,不会误带 deleted 标记。真正危险的是把整个表数据做物理迁移时,直接dumpdata全部记录再loaddata,这时候软删除状态会被真实还原。所以软删除数据迁移时,建议把is_deleted=True的数据统一排除在迁移范围外。
5.5 漏斗看板缓存键没带权限维度,数据串了一个月
现象:销售 A 看到的漏斗金额突然和销售 B 一样,或者主管看的数据里出现了其他人的商机。清掉缓存后数据恢复正常,过一段时间又串。
原因:漏斗看板接口加了 Redis 缓存,但缓存键只写了接口名(比如crm:funnel),没有带上用户 ID 或权限维度。每个销售请求都命中同一份缓存,数据被最后写入的那个人覆盖。
解决:缓存键必须包含用户维度,代码在第 4 章已给出,核心就是cache_key = f'crm:funnel:{request.user.id}'。如果看板还允许按团队过滤,团队维度也要拼进键里。这一条是缓存场景最容易忽视的,出现时往往已经线上跑了一个月,造成的数据困惑非常难追溯。
6. 用 Django Debug Toolbar 给 CRM 做一次 SQL 体检
最后分享一个实战中反复用到的验证习惯:给 CRM 的每个关键页面做一次 SQL 体检。工具就是 Django Debug Toolbar 的 SQL 面板,不需要额外写脚本,打开页面就能看到当前请求执行了多少条 SQL、耗时多少、有没有重复查询。
体检流程固定成四步。第一步,打开 Debug Toolbar 的 SQL 面板,只看总量;第二步,点进页面,记录执行前和执行后各有多少条 SQL,如果差别大于 10 条,说明列表页有隐藏的 N+1 问题;第三步,点开 SQL 记录,找到重复出现多次的相同语句,把鼠标悬停到左边的调用栈上,定位是哪一行代码触发的查询;第四步,回到代码里,用select_related或prefetch_related修复,刷新页面再次对比数量与耗时。
下面是一个典型的对比记录,数据来自对模拟项目 X 的客户列表页优化前后的实测节选:
| 页面 | 优化前 SQL 数 | 优化后 SQL 数 | 优化前耗时 | 优化后耗时 |
|---|---|---|---|---|
| 客户列表(30 条/页) | 90+ | 4 | 约 800ms | 约 120ms |
| 销售漏斗看板 | 8 | 1(聚合) | 约 300ms | 约 80ms |
| 商机详情带全部跟进 | 15 | 3 | 约 450ms | 约 150ms |
发现没有,优化空间的分布非常有规律:列表页和详情页的 N+1 是最大的浪费,聚合页面反而问题不大。所以体检的优先级也应该按这个顺序来:先查高频率访问的列表页,再看详情页,最后管看板。全局搜索一下代码里有没有在for循环里写objects.get或objects.filter的地方,基本就能锁定 80% 的性能问题。
一个额外技巧:把 Debug Toolbar 的配置改成只在本地环境开启,生产环境关闭,不然它会往页面上注入大量调试信息,影响加载速度。如果线上出了问题需要排查 SQL,可以临时开一个只有自己能访问的调试端点,截取慢请求日志,而不是全量开启工具栏。
我在自己的 Django 项目里养成了一个固定习惯:每写完一个列表页或详情页的 view,先用 Debug Toolbar 过一遍,SQL 数超过“主查询 + 2”就要回头查。这个习惯看起来笨,实际省掉了无数次线上“页面怎么突然变慢了”的排查。CRM 这类业务系统,数据量一涨,SQL 数量就是生死线。希望这些经过验证的做法能帮你在做 Django-CRM 时少踩几个坑,把精力留在真正的业务逻辑上。
本文还有配套的精品资源,点击获取