做CRM这件事,最直接的触发点其实很朴素:从2023年下半年开始,我们团队的客户量过了三千这个台阶,原来那套"共享表格+群聊+个人本子"的打法彻底失灵了。客户跟进到哪一步、上次怎么聊的、承诺过什么,全靠人肉记忆。尤其是月底要做预测的时候,每个人报上来的数字都对不上,老板问一句"这个商机到底卡在哪",谁也说不清楚。于是自研一套内部代号为DeskcommCRM的系统这件事,从一句抱怨变成了立项。"Deskcomm"的来由也简单,Desk Communication,我们想要的是让走到哪都能把客户沟通这件事记录清楚、沉淀下来,而不是打开系统就让人头疼。
这套系统的定位不是做个面子工程,而是要解决销售日常里最痛的那几个问题:客户数据不统一、跟进过程散落、商机阶段说不清、跨人交接容易断。本文整理的是从设计到落地这一路的完整思路和踩坑记录,包括数据模型怎么画、状态机怎么定、权限怎么做、并发和慢查询这些"隐藏敌人"要怎么处理。如果你也正在纠结自研CRM,或者公司正准备从表格迁移到系统,这篇应该能给你一些真正能落地的参考。
1. 为什么我会动手自研DeskcommCRM:通用工具在规模化之后的三个死穴
市面上不是没有CRM,我们最早也用过几款轻量的、开源的,甚至试用过一些 SaaS 产品。但真正深度用起来之后发现,通用工具在团队规模小的时候确实省事,一旦业务复杂度和人数上来,三个问题就会集中爆出来。
1.1 数据口径不统一,报表变成"各自解释"
通用CRM最大的问题是字段和流程是官方帮你定好的。它的"商机阶段"可能叫 Proposal、Negotiation,翻译过来就是方案、谈判,但我们的实际销售流程是"加微信-寄样-试用-报价-审批-成交",中间多了一个"试用验证"这个很关键的环节,而且这个环节经常要拖两三个月。通用系统里没有这个阶段,销售只能把单子挂在"方案"或者"其他"里,月底看漏斗图的时候,试用阶段的商机被拆得七零八落。
字段不匹配带来的直接后果是报表失真。老板看的是官方字段的汇总,销售实际推进的是自己脑子里的节奏,两者根本对不上。这是我想自研的第一个理由:我不需要一个别人定义好的业务语言,我需要一个能贴合我们真实打法的数据模型。
1.2 业务状态流转靠口头,交接全靠Excel
另一个痛点是跟进过程没有"半衰期"。一个初次接触的客户,和一个已经进入合同审核的客户,在表格里可能都只是"跟进中"三个字。但这两者的销售策略、需要的资源支持、协同时要注意的事情完全不一样。
我们当时做过一次统计:每周五的周会上,有一半的时间花在同步"客户现在到哪一步了"。但同步完,周一该忘还是忘。尤其是销售请假或者离职的时候,接手的同事拿到一个Excel表,里面几百行记录,根本不知道哪条是重点、哪些承诺已经过期、哪个客户这周必须联系。就是因为这个痛点,我坚定了一个想法:状态流转必须系统化,要能自动追踪、自动提醒,靠人脑记这事一定走不远。
1.3 权限模型太粗,销售Leader不敢放开用
还有一个比较微妙的问题:权限。通用CRM的权限一般就分管理员、经理、普通成员三层。但在真实销售团队里,同一个客户可能有私海、公海的概念,普通销售只能看自己的客户,销售主管能看全组,业务总监能看全部门,但也要能灵活地把某个大客户临时共享给另一个团队的销售协同。通用系统的"所有数据可见"或者"仅自己可见"这种两级开关,根本没法支撑这种交叉又克制的协作模式。
权限搞不定,团队Leader就不敢让销售放心往里录数据。毕竟客户资料是团队最核心的资产,录进去之后谁都能看见,那大家宁愿写在个人微信备注里。这个问题直接决定了系统的生死,也让我在项目启动前就把数据模型和权限模型放到了最高优先级。
2. 从零搭数据模型:一张表一张表设计出来的客户资产
数据模型是整个DeskcommCRM的地基。我见过太多CRM项目,一上来就堆几十张表,字段过百,做到最后自己都搞不清楚哪张表是干嘛的。我们的原则是:先用最少的表跑通核心业务,再逐步演进。
2.1 以"客户"为主表的五张核心表
我从头到尾把业务捋了一遍,最终确定五张核心表:客户表、联系人表、线索表、商机表、跟进记录表。
其中客户表存的是组织的维度,比如公司名称、行业、规模、来源渠道、所属地区。联系人表挂在客户之下,因为一个客户往往有多个联系人,可能IT负责人一个、采购一个、老板一个,每个联系人的角色完全不同。线索表则用来承接还没验证过的原始信息,比如从官网表单、展会名片、市场活动导入进来的数据。线索一旦确认就是有效客户,就可以转为客户,同时关联初始联系人。
商机表是另一个维度的记录,它挂在一个客户下面,代表一次具体的销售机会。一个客户可能有多个商机,比如今年卖他一台设备,明年可能卖他一套软件,这两件事在同一家客户里要分开看。跟进记录表则是所有动态信息的载体,电话、拜访、微信沟通、邮件往来全部落到这里,通过类型字段区分,再用外键关联到客户或商机。
下面这段是我在MySQL里建库时最核心的几个DDL片段:
CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_name VARCHAR(200) NOT NULL, industry VARCHAR(64), source VARCHAR(32) COMMENT '线索来源渠道', owner_id BIGINT NOT NULL COMMENT '负责人', status TINYINT DEFAULT 1 COMMENT '1正常 2冻结 3流失', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_owner_status (owner_id, status), KEY idx_customer_name (customer_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE follow_up ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type TINYINT NOT NULL COMMENT '1客户 2商机', biz_id BIGINT NOT NULL COMMENT '业务对象ID', content TEXT NOT NULL, follow_type TINYINT COMMENT '1电话 2拜访 3微信 4邮件', creator_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_biz (biz_type, biz_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意一点,我在这里用了biz_type + biz_id这种多态关联,而不是直接给跟进记录表加customer_id和opportunity_id两个字段。原因是如果加两个字段,写代码时每次都要判断哪个非空,查询时还容易漏条件,很容易查出biz_id对不上的脏数据。多态关联牺牲了一点外键约束的严谨性,但换来的是代码里的统一处理逻辑,配合应用层校验完全够用。
2.2 状态字段不是随便枚举,要配合业务阶段动态收敛
很多研发在建模时会犯一个错:把所有状态都往一个字段里堆。比如status字段里既放1新建、2跟进中、3已成交,又放99已流失。这样看起来省事,但业务一旦复杂,状态机根本没法维护。
我的做法是状态分维:客户有一个单独的生命周期状态,商机有一个独立的销售阶段状态,线索也有自己的处理状态。三个状态各自独立流转,互不干扰。这样客户可以一直是"正常"状态,但下面既挂着进行中的商机,也挂着已赢单的商机,逻辑就通了。
状态枚举也不是写死在代码里的。我把所有业务对象的状态定义放到了数据库配置表里,管理端可以在界面里调整状态名称和顺序。这样做的好处是后期销售制度调整时,不需要发版改代码,改配置就能生效。
2.3 数据冗余还是联表查询:我的取舍标准
做数据模型时,"客户表里到底要不要冗余一个最新跟进时间、最近联系人和下次跟进时间"这类问题,纠结了挺久。不做冗余,客户列表页要聚合子表,性能会差;做了冗余,又要在每次写跟进时多更新一次客户表,容易出错。
我的最终取舍是这样:客户列表页是销售每天打开最频繁的页面,性能优先级最高,所以冗余了三个字段:last_followed_at、last_follow_content、next_follow_at。这些字段的更新统一收敛在一个事务里——新增跟进记录成功的同时,更新客户表的冗余字段。事务保证了要么同时成功,要么同时失败,不会出现列表页数据和详情页不一致的情况。
3. 技术选型实录:为什么不盲目上微服务
技术选型这件事,我吃过一些亏。早几年做项目总想着用最新的框架、最热的技术栈,微服务、消息队列、容器编排全套上齐,结果团队维护成本扛不住,连部署调试都要折腾半天。DeskcommCRM这次我刻意做了收敛。
3.1 后端框架和存储选型
后端最终定了 Java 17 + Spring Boot 3 + MyBatis-Plus + MySQL 8 + Redis。没有引入微服务相关组件,就是单体应用打包成 Jar,靠 Nginx 部署前后端分离。
这个选择不是保守,而是基于团队现实:我们后端一共就三个人,微服务那一套如果出了问题,排查链路会拉得非常长。单体应用在这个规模下完全够用,MySQL 8 的 InnoDB 引擎配合合理索引,单表千万级数据量也不会有压力。真的到了必须拆服务的时候,那也是半年、一年以后的事情,到时候按业务边界拆开即可,现在不必提前买单。
Redis 主要用来做三件事:验证码存储、登录态 Token 缓存、热数据的短时缓存。比如客户列表页的汇总数字,不会每次都查数据库,而是查 Redis,过期时间只有 30 秒,销售对这个频率没有感知,但数据库的查询压力可以下降不少。
3.2 一个被低估的设计:跟进记录的异步落库
跟进记录是CRM里写入最频繁的数据,尤其是销售打电话多的时候,可能要连续记录好几条。如果把写入请求全部同步处理,插入一条follow_up数据的同时还要更新客户表的冗余字段,在高峰期很容易出现数据库连接池被占满的情况。
我是这样处理的:写入接口收到数据后,先校验参数,然后直接把跟进内容和主键 ID 放进去,主键由业务系统生成,确保幂等。数据写入采用异步批量落库,攒够一定条数或一段时间统一执行批量插入,同时更新客户表的冗余字段。如果某一批写入失败,会有一个补偿任务把失败的数据重新写入。
这个设计的核心价值不是性能提升了多少,而是削峰。销售集中在下午四点以后录跟进,这个时段的写入量是白天的三到四倍,异步削峰之后,数据库的整体负载平稳了很多。
3.3 前端与接口设计的取舍
前端选了 Vue 3 + Element Plus,用 TypeScript 写。之所以选这套,核心原因是生态成熟,招人容易,Element Plus 的表格、表单、弹窗组件在管理系统里非常顺手。
接口设计方面我坚持一个原则:接口按场景设计,而不是按数据表设计。比如客户列表不是简单地返回客户表的字段,而是返回列表页真正需要的数据结构,冗余字段直接拼好,前端拿到就能渲染。这样前端代码简单,但后端接口会多一点,这是值得的,因为主服务端的逻辑可控性比前端强。
4. 线索到商机的状态机:这是我踩坑最多的地方
如果只说DeskcommCRM的一个核心设计,我会选状态机。线索从导入到转化、商机从建立到赢单,中间经历的每一步,都必须有明确的规则,而不能让销售随便改。
4.1 状态机定义:把流转规则写进代码,而不是写进制度
一开始我也犯过这种错:建了一张状态表,然后指望着销售按制度去维护。结果是没人维护,状态渐渐就失真了。后来我调整了思路,把状态流转的规则写成代码,系统强制校验,不允许用户随意跳跃到不合法的状态。
线索的状态我定义得相对简单:
| 当前状态 | 允许流转到 | 触发条件 |
|---|---|---|
| 新建 | 已分配 | 管理员或主管分配负责人 |
| 已分配 | 跟进中 | 负责人点击开始跟进 |
| 跟进中 | 已转化 | 点击转化为客户,填入客户名称 |
| 跟进中 | 已无效 | 填写无效原因,如拒绝、无法联系 |
| 已转化 | 已关闭 | 客户创建成功后线索自动关闭 |
商机状态的流转比线索复杂:
| 当前状态 | 允许流转到 | 触发条件 |
|---|---|---|
| 初步接触 | 需求确认 | 完成需求访谈,有记录 |
| 需求确认 | 方案报价 | 已提交方案或报价单 |
| 方案报价 | 商务谈判 | 客户对报价有意向,进入议价 |
| 商务谈判 | 赢单 | 合同已签署 |
| 商务谈判 | 输单 | 客户选择竞品或本次不采购 |
| 任意状态 | 挂起 | 客户决策停滞,超过30天无跟进 |
这套规则写进代码之后,还配合了一个动作:每次状态变更都会强制要求填写一条跟进记录,内容是状态变更的原因说明。这样改状态不再是一个简单的点击动作,而是一次业务复盘。
4.2 并发场景下的状态错乱与乐观锁方案
状态机设计完,第一个坑很快就来了:销售A和销售B同时打开同一个商机,A把状态从"方案报价"改成了"商务谈判",B不知道,还在原来的页面上把状态改成了"挂起"。结果商机状态被B覆盖了,A看到的"商务谈判"凭空消失。
这个问题的本质是并发覆盖,解决办法是乐观锁。我在商机表加了一个version字段,更新时带上版本号条件,SQL 长这样:
UPDATE opportunity SET status = #{newStatus}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion};执行后如果影响行数是0,说明版本已过期,系统直接提示"当前商机已被其他同事更新,请刷新后再试"。这个方案虽然简单,但非常有效,之后再也没有出现过状态被覆盖的问题。
4.3 自我保护:状态机里必须有的"回退"与"作废"通道
状态机加了限制规则之后,新的问题来了:如果销售不小心把商机从"需求确认"误改成了"赢单",怎么回去?如果状态流转规定"赢单之后不能改回",那这个数据就死在那里了。
我的处理是增加两个特殊通道:
一是回退通道。高级权限的角色(销售主管及以上)可以回退商机状态,但必须填写回退原因,并且系统记录完整的变更日志。这样既避免误操作导致的数据僵化,又保留了审计追踪。
二是作废通道。当商机因为客观原因不再继续推进、但又不能简单标记为输单时,可以走"作废"操作。作废不等于输单,它代表商机本身已经没有继续追踪的价值。这条通道也能防止销售把长期没动静的商机挂到"挂起"状态里永远不处理。
5. 权限与数据隔离:让老板放心、销售不吵的权限体系
权限体系是CRM能否真正用起来的分水岭。我见过不少项目,功能做得很全,但销售同事不愿意用,原因就是"我录的客户别人都能看到",或者反过来,该共享的时候又共享不出来。DeskcommCRM的权限设计我反复推敲了很多轮。
5.1 角色+数据范围+字段级权限三层模型
第一层是角色权限,解决"你能干什么"。管理员、销售总监、销售主管、普通销售、市场专员、只读访客,每个角色有不同的操作权限。比如普通销售只能创建客户、编辑自己名下客户、查看自己的跟进记录;销售主管除了这些还能查看本组成员的客户和商机;销售总监可以看到全部门的数据;只读访客只能看仪表盘和报表,不能进入任何客户详情。
第二层是数据范围,解决"你能看哪些数据"。我采用经典的四级范围:仅本人、本人及下属、本部门、全部数据。这四个范围不是写死在代码里的,而是放在角色配置里,可以按角色灵活调整。数据范围的实现方式是在所有涉及客户、商机的查询上自动追加权限条件。
第三层是字段级权限,解决"你能看到哪些敏感信息"。比如客户的合同金额、利润率、联系方式,普通销售看得到自己的,但同一个客户的另一个联系人手机号,可能只有销售主管才能看到。字段级权限在接口层做拦截,返回给前端的数据中,敏感字段根据权限做脱敏处理。
三层模型的核心原则是"权限只收紧不放宽",低层级的角色永远不可能通过调用接口的方式看到高一级的数据,即使他知道某个客户ID。
5.2 数据权限拦截器:一处配置,全局生效
权限逻辑如果散落在每个 Service 方法里,代码会变得非常冗长且容易遗漏。我把数据范围过滤做了一个拦截器,通过注解的方式标注在每个查询方法上。
@RequiresDataScope(bizType = "customer") public PageResult<CustomerVO> pageCustomer(CustomerQuery query) { // 业务逻辑 }拦截器解析当前登录用户的角色和数据范围,然后自动拼接 SQL 条件。比如数据范围是"仅本人",拦截器会在 SQL 上追加AND owner_id = #{userId};如果是"本部门",就追加AND owner_id IN (SELECT id FROM user WHERE dept_id = #{deptId})。这样做的好处是,业务代码完全不用关心权限过滤逻辑,新加一个查询方法,只要加上注解,权限就自动生效。
5.3 操作审计日志:权限变更要有据可查
权限设计得再严谨,也扛不住"内鬼"或"误操作"。所以DeskcommCRM里所有的敏感操作都有审计日志:谁在什么时间把哪个客户从A销售转给了B销售、谁把商机的金额从20万改成了2万、谁批量导出了客户数据。这些日志写入独立的审计表,普通销售没有查询权限,只有管理员和合规岗位能看。
审计日志带来的直接价值是震慑和可追溯。有一次一个销售离职前批量导出了自己名下的两千条客户数据,审计日志记录了他的操作时间和导出条数。这件事虽然没有直接闹到法律层面,但让整个团队对数据安全有了敬畏心,后续再也没人敢乱动客户数据。
6. 上线之后遇到的三个真实故障排查
系统上线到稳定运行,中间遇到的故障远比想象中多。这里挑三个比较典型的,记录一下完整的排查链路。
6.1 跟进记录"神秘消失":事务边界与异步线程的坑
故障现象:销售反馈说某段已保存的跟进记录第二天再看不见了,但列表页能看到客户的"最新跟进时间"是有的,点进详情却看不到跟进的正文。
排查链路:先查了数据库,发现follow_up表里有这条记录,说明数据没丢,是查询出了问题。接着查列表页接口,发现列表页返回的last_follow_content是从客户表冗余字段里取的,有值;但详情页的跟进记录列表查的是follow_up表,按biz_id过滤,结果为空。
最后定位到问题根源在异步落库逻辑:列表页的last_follow_content是同步更新的,而follow_up表的数据走异步批量写入,两者不在同一个事务里。当异步任务积压或失败时,就会出现"列表有更新、详情无记录"的情况。
解决办法是把同步更新和异步写入做了统一的事务保证。具体的做法是:新增一条独立的follow_up_pending队列表,业务请求先把跟进内容写入这个表,同时更新客户表的冗余字段,两项操作在同一事务内完成;后台批处理任务再把这个 pending 表的数据批量搬到follow_up表并标记为完成。这样哪一步失败都能重试,不会出现数据不一致。
6.2 大客户列表越开越慢:索引与分页优化
客户量到了五万左右时,销售开始反馈客户列表页打开要三四秒,有的甚至要五六秒,基本没法用。
排查过程分两步。第一步看 SQL,发现列表页的复杂查询带了六个关联条件:客户、联系人、负责人、最后跟进时间、下次跟进时间、所属地区。其中ORDER BY updated_at DESC这一句特别慢。第二步看执行计划,发现排序字段updated_at没有索引,MySQL 只能 filesort,数据量大时自然慢。
优化方案是组合索引和覆盖索引。给客户表建了(owner_id, updated_at)组合索引,让查询能在索引里完成排序和过滤。同时列表页不再查询大字段(比如last_follow_content截断为前50个字符),减少回表的数据量。优化之后,同样的数据量,列表接口从四秒降到了三百毫秒以内。
另一个优化的点是分页。原来的分页是经典的OFFSET + LIMIT,页码越深越慢。我改成了"游标分页",用WHERE updated_at < #{lastUpdatedAt} ORDER BY updated_at DESC LIMIT 20,配合组合索引,查询效率一直是常数级别。
6.3 离职销售交接数据:一次性导入引发的重复客户合并
销售离职后,他名下的客户要成批转给其他同事。这本来是一个管理动作,但交接时不小心把同一家客户的多个联系人数据重复导入了多次,客户表里出现了超过一百个重复客户记录。
这类问题的根源是导入时没有做去重判断。我后来在导入逻辑里加了客户名称+联系人手机号+公司域名的三重匹配规则:导入前先检查这些字段,如果匹配到已有客户,就不新建,而是主动提示"已有同名客户,是否合并或创建新客户"。
对于已经存在的重复数据,我写了一个合并工具:选择主客户后,系统会把重复客户的联系人、跟进记录、商机全部迁移到主客户名下,同时把重复客户标记为"已合并"状态,并记录合并前的关系映射,方便找回。
这个合并工具后来成了我们团队高频使用的功能。销售误操作或者市场导入不干净数据时,管理员可以直接在后台合并,不用再让研发帮忙写 SQL。
7. 这轮自研周期的个人复盘与后续规划
DeskcommCRM从立项到稳定运行,前后用了不到四个月。这四个月里,踩过的坑不少,但整体路线下来,我对自研CRM的判断越来越笃定。
最核心的体会是:自研不等于从零造轮子,而是把业务理解揉进技术方案里。通用CRM的字段、流程、报表都是别人定义的,不一定符合你的销售打法。自研真正的价值是能把状态机、权限模型、数据统计口径这些决定业务效率的东西,按自己团队的方式定制出来。
顺便分享一个小技巧:如果团队预算和人力允许,CRM项目里一定要留一个"业务分析师"的角色,哪怕是兼职。这个角色不一定写代码,但要非常懂销售流程,能把销售的"黑话"翻译成技术团队听得懂的需求。我们后期很多关键优化,比如商机阶段的调整、客户分层的逻辑,都是从这个角色的建议里来的。
后续的规划,我主要关注三个方向。第一个是客户生命周期的数据洞察,当前数据已经积累了不少,下一步准备按行业和客户规模维度做流失预警和复购预测。第二个是移动端体验,销售在路上的时候打开电脑不方便,目前做了一个简易版移动端适配,但交互还需要打磨。第三个是把公海池机制做得更完善,让长时间未跟进、被系统自动回收的客户能够公平地重新分配到活跃销售手上,这是销售团队对系统满意度影响比较大的一个功能。
做系统这件事,没有终点。业务在变、团队在变,系统就得跟着变。只要一线的同事愿意用、用完之后觉得省事、有数据沉淀下来,这四个月的投入就值了。