ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自研CRM实战:从客户全景视图到通信留痕的架构与设计

自研CRM实战:从客户全景视图到通信留痕的架构与设计

不少做销售团队的朋友都问过我同一个问题:市面上CRM一大堆,免费的、付费的、云端部署的、私有化定制的,为什么还要自己折腾一套“DeskcommCRM”这样的系统?

答案其实很简单:别人做的是标准品,但我们团队要的是合身的衣服。

提到CRM,大家第一反应是“管客户”。但真正用过的人会告诉你,管客户只是表面,背后是不同行业、不同团队、不同业务流程的“适配战争”。过去两年我们试过好几套通用CRM,业务部门永远在提需求:销售说录入太麻烦,客服说客户信息不同步,主管说数据报表隔天才能看。到最后,CRM沦落成“把客户名单填进去存着”的电子表格,这完全违背了CRM的初衷。

这个项目“DeskcommCRM”就是我为了把客户管理从“记账”变成“经营”而做的自研系统。它解决的问题很具体:一是让每一次与客户的互动都有迹可循,二是让每个业务角色用最少的点击完成工作,三是让管理层看到的永远是最新最准的数据。如果你也在考虑自研CRM,或者正在挑选CRM但始终犹豫,这篇博文里的设计思路、踩坑记录和关键代码思路,应该能帮你省掉不少弯路。

1. 为什么我不再直接用现成CRM,而是自研了DeskcommCRM

先说说我决定自研的导火索。当时我们团队四十多人,一半销售一半客服。用了将近一年的商业CRM,问题越来越明显,不是软件不好,而是流程不匹配。那种感觉就像买了一套精装房,隔断墙都是按别人家的生活习惯砌的,我们只能凑合住。

1.1 旧方案的三座大山:信息割裂、操作繁琐、数据不可用

第一座大山是信息割裂。销售在CRM里录入客户资料,客服在另一个工单系统里处理售后问题,通话记录又在电话交换机里存着。三个系统互不相通,结果就是销售打电话给客户之前,根本不知道这个客户上周刚投诉过物流。客户体验差,内部协作也累,每天光对齐信息就要花不少时间。

第二座大山是操作繁琐。通用CRM为了覆盖各种行业,表单字段特别多,新建一个客户要填二十多个必填项。销售在外面跑一天,回家还要对着电脑补录入,日子长了就应付了事,信息质量越来越差。系统里大量客户资料停留在一两年前,连联系电话号都没更新。

第三座大山是数据不可用。报表是每天凌晨定时跑批的,主管看到的永远是昨天的数据。再想做一些定制分析,比如“按区域、按产品线、按客户星级交叉看回款趋势”,后台设置又复杂,最后只能把明细导出来用Excel手动做。

1.2 DeskcommCRM要解决的核心需求:客户全景视图、通信留痕、团队协作、数据驾驶舱

在动工之前,我拉上销售主管、客服主管和一线销售代表聊了好几轮,最后把需求收敛成四个核心词。

第一个是客户全景视图。我们要的不是一堆字段,而是一个客户从首次线索到成交到售后的完整时间轴:谁录入的、什么时候跟进过、聊了什么、发过什么资料、有没有下单、有没有售后问题。任何一个人打开这个客户页面,三秒钟内就能知道全部来龙去脉。

第二个是通信留痕。销售和客户的沟通主要靠电话。系统需要和电话交换机对接,自动记录每一通电话的时间、时长、通话录音,并且自动关联到对应客户。这一条能省掉销售大量手动记录的工作,也为后续做话术复盘提供素材。

第三个是团队协作。销售之间可以互相转交客户,主管能查看团队跟进情况,客服能把客户问题和对应的销售线索串起来,避免“销售不知道客户已经投诉过”的尴尬。

第四个是数据驾驶舱。管理层要实时看到线索量、跟进次数、转化率、回款金额这些核心指标。不需要Excel,打开系统就能看,还要支持按时间、按人员、按来源下钻。

2. DeskcommCRM整体架构与设计思路

需求清楚了,接下来就是技术选型与架构设计。我没打算做重架构,本质上这是一个支撑几十人团队的业务系统,高并发不是首要矛盾,重点是开发效率、后续可维护性以及信创环境下的可替代性。

2.1 技术选型:为什么选这套组合

后端我选了Python的FastAPI框架,前端用Vue3 + Element Plus,数据库用PostgreSQL,缓存用Redis,部署直接用Docker Compose。

有些人可能觉得后端用Java更保险,但对我们这种业务密集型系统,FastAPI的优势很明显。它基于类型注解自动生成API文档,前端对接接口时不用反复问字段含义;异步原生支持让长轮询、WebSocket实时推送都很好写;再加上我们是销售团队,Python写数据清洗脚本、统计报表,生态里的pandas、openpyxl用起来非常顺手。

数据库选PostgreSQL而不是MySQL,主要看中三点。一是JSONB类型,客户的自定义属性可以灵活存储,不用频繁改表结构;二是自带行列级锁和MVCC,支撑几十人的并发操作绰绰有余;三是强大的全文检索和窗口函数,后面做数据分析和搜索很方便。

前端之所以选Vue3 + Element Plus,是因为团队里前端水平以“能改”为主,Vue的上手成本低,Element Plus组件库覆盖了大量后台管理页面的常用交互(表格、表单、弹窗、日期选择器),一个合格的后端工程师也能在半个小时内把页面搭起来。

这里要说明一下,这套技术栈适合业务逻辑重、并发量中等、需要快速迭代的内部系统。如果是面向C端的高并发场景,架构思路就要完全重来。

2.2 数据模型设计:客户、联系人与跟进记录的核心关系

整个系统的数据模型围绕三个核心实体展开:客户(Customer)、联系人(Contact)、跟进记录(Activity)。

客户表存的是公司级信息,比如客户名称、所属行业、客户星级(S/A/B/C)、来源渠道(广告、转介绍、展会等)、状态(线索、潜在、成交、流失)。联系人表挂在客户下面,一个客户可以有多个联系人,每个联系人独立存姓名、职位、电话、微信、邮箱等。跟进记录表是整张时间轴的基础,任何一次电话、见面、微信沟通、邮件往来,都往这张表里写一条记录,并关联负责人、关联客户、关联联系人、关联待办任务。

核心表结构大致如下:

-- 客户主表 CREATE TABLE customer ( id BIGSERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, industry VARCHAR(100), star_level CHAR(1) DEFAULT 'B', source VARCHAR(50), status VARCHAR(20) DEFAULT 'lead', owner_id BIGINT REFERENCES sys_user(id), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- 联系人表 CREATE TABLE contact ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT REFERENCES customer(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, position VARCHAR(100), phone VARCHAR(30), wechat VARCHAR(100), email VARCHAR(100) ); -- 跟进记录表 CREATE TABLE activity ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT REFERENCES customer(id) ON DELETE CASCADE, contact_id BIGINT REFERENCES contact(id), owner_id BIGINT REFERENCES sys_user(id), activity_type VARCHAR(30), -- phone_call / visit / wechat / email content TEXT, next_follow_date DATE, created_at TIMESTAMPTZ DEFAULT now() );

这个结构看起来简单,但后面所有功能都是围绕它展开的。客户360度视图页就是把这三张表的数据串起来,按时间倒序展示跟进记录和通话记录;主管看板就是把这三张表按维度聚合统计。建模时最忌讳一上来就设计二十多张关联表,先从最小闭环开始,后面按需求添加,反而更稳。

2.3 权限模型与多角色支持

权限模型我直接采用了成熟的RBAC(基于角色的访问控制)方案,没有自己发明轮子。系统里有三个内置角色:销售、销售主管、管理员。销售只能看到自己名下的客户;主管能看到自己团队所有成员的客户;管理员拥有全部权限,包括系统配置、用户管理、数据导出等。

在实现上,我把数据权限限定做了两层。第一层是功能权限,控制某个角色“能不能访问某个菜单”。第二层是数据范围权限,控制“能访问哪些数据”。数据范围用几个枚举值标识:本人、本部门、全部。查询时在SQL里自动拼接条件。比如销售用户在查询客户列表时,系统自动加一个 owner_id = 当前用户ID 的过滤条件,避免在代码里到处判断角色导致漏网之鱼。

这条设计后来在审计时帮了大忙。对比之前用商业CRM时权限被某个超管账号一杆子捅到底,现在什么时候谁看了哪个客户的资料,全部有迹可循。

3. DeskcommCRM核心功能模块拆解

架构和数据模型确定之后,最有意思的部分就是各个功能模块的落地。这里挑几个重点模块,讲一下具体的实现思路和遇到的坑。

3.1 客户与联系人管理:从录入到360度画像

客户录入这个环节,我们重点攻克了“录入难”的问题。

第一版我也学通用CRM,做了一大堆必填字段。结果上线三天就被销售骂了:新增一个客户要填公司名、统一社会信用代码、法人、行业、规模、详细地址……光表单加载就要好几秒。后来我狠下心把必填字段砍到了三个:客户名称、手机号(或座机号)、来源渠道。其他全部改为非必填,可以等后续跟进中慢慢补齐。录入体验立刻改善,销售新增客户的意愿明显提升。

客户列表页做成了高聚合的“卡片列表”模式,每一行显示客户名称、星级、状态、所属行业、最近跟进时间、下次跟进日期。表格支持按状态筛选、按星级排序、按关键词搜索,跟进的销售甚至可以利用快捷筛选看“今天需要跟进的客户”和“超过7天没跟进的客户”,不需要主管催,自己就把优先级排出来了。

360度客户画像页是整个系统使用频率最高的页面。页面顶部是客户的基本信息卡片,底下分Tab展示跟进记录、联系人列表、通话记录、订单记录、售后工单。这里的关键点是所有数据都要在一秒内加载出来。我在后台做了聚合查询的接口,一次联表查出所有Tab的数据,前端切Tab时不需要再发请求,体验非常流畅。

3.2 跟进记录与任务提醒:销售过程管理的关键

很多CRM把跟进记录做成了“流水账”,销售每次写一大堆内容,管理者根本不会看。这套系统里我换了一个思路:跟进记录不仅仅是记录,更是驱动下一步行动的引擎。

每条跟进记录除了填写沟通内容和方式(电话/见面/微信),还要设置“下次跟进日期”。保存之后,系统自动在待办列表里生成一条任务提醒。到了第二天,工作台首页会显示今天应跟进的客户列表,点击名单直接就跳转到客户画像页,省去搜索的时间。

为了降低记录的抗拒感,我在手机上做了“快捷记录”入口,销售刚挂电话随时可以录一句要点。比如“王总说下周二才开会定供应商,到时候再联系”。系统自动把这句话存为该客户的最近跟进记录,并顺延下次跟进日期。

这里还藏了一个用心的小设计:跟进次数会被纳入数据看板,作为销售活跃度的关键指标。但不看绝对次数,而是看“有效跟进率”——即具备下一步动作的跟进记录占比。销售不会为了刷数据而乱录了,因为录了没有设置下次动作,照样不算有效。

3.3 通信留痕与智能弹屏:Deskcomm的“Comm”到底做了什么

项目名里带“Comm”,本质就落在通信集成上。我们的办公室用的是自建的FreeSWITCH电话交换机,销售通过IP话机或者软电话外呼。过去通话记录分散在交换机后台,销售还得自己记“今天给谁打了电话”,特别费劲。

DeskcommCRM和电话交换机对接之后,变成了一套“点击拨号+通话自动记录”的方案。销售在系统里点客户旁边的电话图标,系统通过接口通知话机拨号,同时创建一个“通话中”的临时记录。

通话结束时,交换机会通过WebSocket回调一条通话状态消息给系统,包括双方号码、开始时间、结束时间、通话时长,如果配置了录音,还会带上录音文件的URL。系统拿到消息之后做的事比较关键:

  • 通过被叫号码倒查客户和联系人,找到该号码关联的客户;
  • 在客户时间轴里自动生成一条通话记录,显示“已通过电话,通话时长4分32秒”;
  • 给销售弹出一个快捷记录框,提示“是否要为本次通话补充内容”。

销售只要不点取消,这条通话记录就算入账。这样既保证了信息完整,又没给销售增加额外负担。实际上线之后,大家反馈录音回放这个功能的实用价值最高。新员工不知道怎么跟客户开口,翻出老销售的优秀通话录音听一听,比看十篇培训文档都管用。

3.4 数据看板与实时统计:管理层最喜欢的功能

数据看板最早是主管的需求,后来做着做着我发现,销售自己也爱看。

整体看板页面包含几个核心卡片:今日新增线索数、今日跟进次数、今日通话总时长、当前客户总量、本月成交金额。下面是几个趋势图:近7天新增线索趋势、近30天转化漏斗(线索-意向-报价-成交-回款),以及销售个人业绩排行榜。

这些指标的数据从哪来?全靠实时SQL聚合,不引入单独的大数据组件。因为数据量级几千到几万条,PostgreSQL的窗口函数和物化视图完全能扛住。为了减少频繁聚合对主库的压力,我在Redis里做了5分钟的短缓存。也就是说,最坏情况下页面数据延迟5分钟,但正常使用几乎感觉不出来,主管也不需要等隔天的日报了。

一个比较有用的视图是主管的“团队跟进制衡图”。横轴是团队成员,纵轴是有效跟进次数。主管每天早会前扫一眼,马上就知道前一天谁在干活、谁在摸鱼。这不是为了监控而监控,而是给管理提供数据依据,让那些认真跟进客户的销售得到应有的认可。

4. 从零到一:DeskcommCRM关键环节实现

理论说了一堆,进入实际操作环节。这里我会详细拆解三个关键的实现:本地环境搭建、Excel客户批量导入、以及权限控制的代码落点。

4.1 用Docker Compose快速搭建开发环境

开发环境我直接用Docker Compose统一管理中间件,后端和前端代码挂载到容器里,修改即时生效。

version: "3.8" services: postgres: image: postgres:15 container_name: deskcomm_postgres environment: POSTGRES_USER: deskcomm POSTGRES_PASSWORD: deskcomm123 POSTGRES_DB: deskcomm ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7 container_name: deskcomm_redis ports: - "6379:6379" backend: build: ./backend container_name: deskcomm_backend command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload ports: - "8000:8000" volumes: - ./backend:/app environment: DATABASE_URL: postgresql://deskcomm:deskcomm123@postgres:5432/deskcomm REDIS_URL: redis://redis:6379/0 depends_on: - postgres - redis frontend: build: ./frontend container_name: deskcomm_frontend command: npm run dev ports: - "5173:5173" volumes: - ./frontend:/app depends_on: - backend volumes: pgdata:

注意几个细节。后端容器里通过环境变量读取数据库连接串,而不是写死在代码里,这样换环境不用改代码。数据库容器挂了数据卷,重启后数据不丢。开发环境真正跑起来只需要一条docker-compose up。

4.2 客户Excel批量导入:pandas + 批量写入的实现

很多团队在从Excel往系统里导入数据时都吃过亏。几百行数据好办,但如果一次性往里灌五千行,很容易出现超时或者内存爆炸。

我用的方案是把导入拆成三部分:解析、清洗、写入。

解析用Python自带的openpyxl库,直接读取Excel工作簿。清洗用pandas DataFrame处理,把Excel里的列名映射到系统字段名,比如把“公司名称”列改写为 name,把“手机号”改写为 phone。清洗时顺便做类型转换和去重:手机号统一转成字符串,去空格,去横线;同一手机号在表里出现多行的,默认保留第一行,并把重复行汇总报告给用户。

清洗完成之后,写入数据库没有用ORM一条条循环引入,因为五千条循环要近一分钟。这里用psycopg2的execute_values批量写入,5000行数据实测只需要两三秒。代码如下:

import psycopg2 from psycopg2.extras import execute_values conn = psycopg2.connect(DSN) cursor = conn.cursor() data = df[["name", "industry", "star_level", "source", "owner_id"]].values.tolist() sql = """ INSERT INTO customer (name, industry, star_level, source, owner_id, created_at, updated_at) VALUES %s """ execute_values(cursor, sql, data, page_size=1000) conn.commit()

我自己踩过一个坑:导入时没有事务包裹,中途某一行数据有问题,前面导入的行也被提交了,结果产生了大量半截子数据。后来改成先全部导入临时表,校验通过后再复制到主表。如果中途出错,临时表直接清空,不影响线上数据。这个稳妥的做法虽然多了一步,但对数据安全很有必要。

4.3 权限控制的代码落点:SQL层面过滤数据范围

权限控制如果只在菜单上做隐藏,数据仍然可能出现越权访问。我曾经见过一些系统,销售猜出URL参数就看到了其他团队的客户。所以在DeskcommCRM里,权限控制的关键落点在数据库查询层。

用一个统一的函数处理数据范围过滤:

from sqlalchemy import or_ def get_scoped_query(model, user): """根据用户角色返回过滤后的查询条件""" if user.role == "admin": return model.id.in_(select(model.id)) if user.role == "manager": team_user_ids = get_team_user_ids(user) return model.owner_id.in_(team_user_ids) # 普通销售只能看到自己的 return model.owner_id == user.id

所有涉及客户列表、联系人、跟进记录的查询,都强制走这个函数拼接条件。接口层不直接信任前端传的任何ID参数,一律以当前登录用户的权限范围为准。

还有一点容易被忽略:删除和编辑操作也需要同样的权限校验。用户提交修改请求的时候,后端要再次检查这个客户是否属于当前用户的数据范围,不能只在前端界面上隐藏按钮。我见过有些系统只在界面上控制,数据接口暴露了Update接口,结果被有心人利用。

5. 上线之后:我踩过的坑和排查实录

系统上线后并不是万事大吉,反而最锻炼人的阶段才开始。这里整理三个很典型的线上问题和排查过程。

5.1 同名称客户合并导致的关联数据错乱

第一个坑出现在导入老客户资料的时候。当时表里已经有了一家“华信科技有限公司”,导入的新Excel里也有一家“华信科技有限公司”。系统按“同名即同一家客户”的逻辑,直接把Excel里的数据合并进了已有客户。

问题来了:Excel里的联系人“张三”已经有手机号,而系统里此前已经录过“张三”同一家公司但不同手机号的记录。合并后,系统生成了两个张三,并且后续关联的通话记录一会儿挂在旧张三下面,一会儿挂到新张三下面,客户历史时间轴直接乱了套。

排查过程花了一天。先是梳理了客户合并时的关联关系,把所有历史记录统一迁移到主联系人ID下;然后修改了合并逻辑,不再简单按客户名称合并,而是要求“客户名称 + 联系人手机号”同时匹配才算重复,否则一律新建客户。这个改动的代价是导入速度慢了一点点,但对数据准确性的提升是决定性的。

现在导入时还会预先弹一个新客户与已有客户的相似名单,让导入者确认是“继续新建”还是“合并到已有客户”,把选择权交给业务人员,系统不替他们做决定。

5.2 通话记录重复入库

第二个坑发生在我做通信对接测试时。FreeSWITCH的回调机制在偶发情况下会重复推送同一条通话记录。原本设计的是“按通话唯一ID去重”,但我当时偷懒没有加唯一约束,结果后台出现了一堆完全重复的通话记录。

解决方式在数据库层级加了一个唯一索引:

CREATE UNIQUE INDEX idx_call_unique ON call_log (external_call_id);

外部通话ID在交换机推送的消息里是唯一的。回调和手动补偿推送即使重复,数据库也会拒绝插入重复记录,应用层不需要额外写判断。这个教训告诉我,凡是外部接口推送的数据,一定要在落库时做幂等处理,否则上游一抖动,下游就开始乱套。

5.3 Excel导出与大数据量下的内存优化

第三个坑是导出客户列表时的内存问题。业务部门特别喜欢用筛选条件导出客户明细,尤其是“导出所有华东地区的S级客户”这类操作,不小心就触发上百万行的数据。代码早期直接to_excel会把所有导出的数据全部加载到内存,然后一次性写入文件,几次大导出之后,服务器内存报警,其他服务也跟着遭殃。

后来改成分页流式查询,用游标一条条读取数据,每凑齐5000行就写一批到Excel文件里,写完一批释放一批内存。

import pandas as pd from sqlalchemy import text def export_large_query(sql, filename): offset = 0 batch_size = 5000 first = True while True: rows = db.execute(text(sql).bindparams(offset=offset, limit=batch_size)).fetchall() if not rows: break df = pd.DataFrame(rows) if first: df.to_excel(filename, index=False) first = False else: with pd.ExcelWriter(filename, mode="a", engine="openpyxl", if_sheet_exists="overlay") as writer: df.to_excel(writer, startrow=writer.sheets["Sheet1"].max_row, index=False, header=False) offset += batch_size

实际用下来,三百万行数据导出时间从原来的二十多分钟降到了七分钟,内存占用也稳在了一个较低的范围内。这里要注意,分批写Excel时用if_sheet_exists="overlay"才能追加数据,不然会反复覆盖第一个Sheet。

6. 使用DeskcommCRM一年后的一些沉淀

系统上线已经一年多,要说最大的收获,不是代码量本身,而是我搞明白了一个道理:好的CRM不是在软件层面做到多强大,而是要让业务人员觉得“这个系统好用,我愿意用,用它让我的工作变简单了。”

DeskcommCRM在功能之外,我还沉淀了几条经验,写下来给想自研工具的朋友参考。

第一,先做减法再做加法。第一个版本就是客户管理、跟进记录、任务提醒这三大块。那些花哨的AI预测、客户画像标签、批量邮件营销,等基础模块用顺手了再往上面加。一上来就奔着大而全,大概率半年都上不了线。

第二,把业务人员当成产品的共同设计者。销售代表提出的“挂电话后自动弹屏记录”这个需求,比我坐在办公室里想的任何功能都更能提升系统价值。每周拉一个业务人员坐着看他们操作,比收集一堆反馈工单要高效得多。

第三,数据准确性比功能丰富更重要。客户数据乱了,后面做任何分析都是误导决策。为了保障数据质量,多花时间在导入校验、去重合并、权限隔离这些“内功”上,比多开发几个花哨页面有价值得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进