# 别把 CRM 做成高级通讯录:从零设计一个能用的客户管理系统 > 很多团队做 CRM,最后做成了"客户信息录入表"。这篇不讲技术框架,先讲清楚 CRM 的业务模型该怎么设计,再说怎么落地。 做过客户管理系统的开发者,大概率遇到过这种对话: 业务方:"我们要一个 CRM。" 开发:"好的,客户表、跟进记录表、成交记录表,三天搭完。" 上线一个月后,业务方:"这个 CRM 没人用,销售还是用 Excel。" 为什么?因为大部分 CRM 做成了"高级通讯录"——只能录客户信息,不能驱动销售行为。 真正能用的 CRM,核心不是"存客户数据",而是解决三个业务问题: 1. **客户归谁**(公海私海机制) 2. **客户该跟进了吗**(跟进节奏与回收) 3. **客户成交了吗**(状态流转与统计) 这篇就把这三个问题讲透,再给出技术落地方案。 --- ## 一、CRM 的核心数据模型 先别急着建表,先想清楚 CRM 里有哪些核心实体。 ```text 客户(Customer) ├── 联系人(Contact) 一个客户多个联系人 ├── 跟进记录(FollowUp) 一个客户多条跟进 ├── 成交记录(Deal) 一个客户多笔成交 └── 状态变更日志(StatusLog) ``` | 实体 | 核心字段 | 说明 | |------|----------|------| | 客户 | 名称、来源、行业、级别、状态、负责人ID、所属部门 | CRM 的主体 | | 联系人 | 姓名、电话、职位、客户ID | 一个客户多个联系人 | | 跟进记录 | 客户ID、跟进方式、跟进内容、跟进时间、下次跟进时间 | 驱动销售行为的核心 | | 成交记录 | 客户ID、成交金额、成交时间、订单号 | 统计用 | 这张表看着简单,但很多团队在第一步就埋了坑:**把所有信息塞进一张客户表**。客户表 30 个字段,连联系人电话、最近一次跟进内容都塞进去。结果是:一个客户多个联系人时没法存,跟进记录无法追溯。 **原则:客户表只存客户本身的属性,联系人和跟进记录独立成表。** --- ## 二、公海私海:CRM 最核心的业务设计 这是区分"高级通讯录"和"真正 CRM"的分水岭。 ### 什么是公海私海? - **私海**:分配给某个销售负责的客户,只有该销售能看到和跟进 - **公海**:没有负责人的客户池,所有销售可以领取 为什么需要这个机制? 没有公海私海的 CRM 会出现两个极端: 1. **所有客户所有人都能看** → 销售互相抢单、客户被重复骚扰 2. **客户分配后永久归属** → 销售囤积客户不跟进、离职带走客户 公海私海的本质是:**客户是公司的资产,不是个人的资产。公司可以把客户分配给销售,也可以收回。** ### 数据库怎么设计? 客户表加三个字段: ```sql customer_id BIGINT -- 客户ID owner_id BIGINT -- 负责人ID(NULL=公海) owner_time DATETIME -- 领取/分配时间 protect_expire DATETIME -- 保护期截止时间 ``` - `owner_id IS NULL` → 公海客户 - `owner_id = 123` → 销售私海客户 - `protect_expire` → 保护期内不能被回收 ### 公海的三种来源 | 来源 | 场景 | |------|------| | 新客户录入无负责人 | 市场部导入线索,未分配 | | 销售主动放弃 | 销售判断无法成交,退回公海 | | 系统自动回收 | 超过 N 天未跟进,自动回公海 | --- ## 三、自动回收:逼着销售去跟进 公海私海有了,但如果销售领了客户就放着不跟进,私海就变成了"囤货"。 所以需要一个**自动回收机制**:超过 N 天没跟进的客户,自动从私海回到公海。 ### 回收规则怎么设计? ``` 如果 客户在私海(owner_id 不为空) 且 当前时间 - 最后跟进时间 > 回收阈值 且 不在保护期内 则 自动回收到公海(owner_id 置空) ``` 回收阈值按客户级别区分: | 客户级别 | 回收阈值 | |----------|----------| | A(重要) | 30 天 | | B(普通) | 15 天 | | C(一般) | 7 天 | 重要客户给销售更多时间,一般客户快速回收。 ### 保护期是什么? 刚分配/领取的客户,给一个保护期(比如 3 天),保护期内即使没跟进也不回收。避免刚领就被别人抢走。 ``` protect_expire = 领取时间 + 3天 ``` ### 怎么实现? 两种方式: 1. **定时任务扫描**:每天凌晨跑一遍,找出该回收的客户 2. **懒检查**:每次访问客户详情时检查,超时则自动回收 建议用定时任务,稳定可控。核心 SQL(写在 Mapper XML 里): ```sql UPDATE customer SET owner_id = NULL, owner_time = NULL, protect_expire = NULL WHERE owner_id IS NOT NULL AND protect_expire < NOW() AND id IN ( SELECT c.id FROM customer c LEFT JOIN ( SELECT customer_id, MAX(follow_time) AS last_time FROM customer_follow_up GROUP BY customer_id ) f ON c.id = f.customer_id WHERE COALESCE(f.last_time, c.owner_time) < DATE_SUB(NOW(), INTERVAL 15 DAY) ) ``` --- ## 四、客户状态流转:线索到成交的完整链路 客户不是静态的,会从"线索"一路走到"成交"或"流失"。状态流转设计不好,销售就不知道该干什么。 ### 标准状态流转 ```text 线索 → 跟进中 → 意向 → 谈判 → 成交 ↘ 流失 ↘ 战败 ``` | 状态 | 含义 | 销售该做什么 | |------|------|-------------| | 线索 | 刚录入,未确认有效性 | 联系客户,判断意向 | | 跟进中 | 已联系,需求待挖掘 | 持续跟进,了解需求 | | 意向 | 有明确需求 | 提供方案、报价 | | 谈判 | 方案已报,在推进 | 谈条件、促成交 | | 成交 | 已签约 | 交付、维护 | | 流失 | 客户主动放弃 | 分析原因 | | 战败 | 跟进失败 | 复盘、归档 | ### 状态不能随便跳 状态流转要有约束。不能从"线索"直接跳到"成交"——中间至少要经过"跟进中"。 建议用状态机管理,每次状态变更记录日志: ```sql -- 状态变更日志 INSERT INTO customer_status_log (customer_id, from_status, to_status, operator_id, remark) VALUES (123, 'FOLLOWING', 'INTENTION', 456, '客户确认有采购需求'); ``` 这样三个月后老板问"这个客户怎么从线索变成成交的",有完整的轨迹可查。 --- ## 五、数据权限:销售只能看自己的客户 CRM 的数据权限是硬需求: - 销售只能看自己私海的客户 - 部门主管能看本部门所有销售的客户 - 总监能看本部门及子部门 - 老板能看全部 - 公海客户所有人能看 如果用 `if/else` 在每个查询里判断,代码会很乱。这就是数据权限拦截器该干的事。 用 Forge 的 DataScope 机制,按 `mapperMethod` 配置: | 角色 | 数据范围 | 效果 | |------|----------|------| | 销售 | SELF(本人) | 只看 `owner_id = 当前用户` 的客户 | | 主管 | ORG(本组织) | 看本部门所有销售负责的客户 | | 总监 | ORG_AND_CHILD | 看本部门及子部门 | | 老板 | ALL | 看全部 | 在 `sys_data_scope_config` 里配一行: ``` mapperMethod: com.xxx.mapper.CustomerMapper.selectPage tableAlias: c userIdColumn: owner_id orgIdColumn: owner_dept_id ``` 拦截器会在 SQL 的 WHERE 后自动追加 `c.owner_id = 当前用户` 或 `c.owner_dept_id IN (...)`,业务代码完全不用管。 公海客户的查询单独配一个方法,不加数据权限: ```java // 私海客户列表(受数据权限控制) List selectPage(@Param("dto") CustomerQueryDTO dto); // 公海客户列表(所有人可看,不拦截) @IgnoreDataScope // 或不配 data_scope_config List selectPublicPool(@Param("dto") CustomerQueryDTO dto); ``` --- ## 六、跟进记录:驱动销售行为的核心 很多人把跟进记录做成"备注"——一个文本框,随便填。这是错的。 跟进记录是 CRM 里最重要的业务实体,它驱动销售行为,也决定客户是否被回收。 ### 跟进记录该有哪些字段? | 字段 | 说明 | |------|------| | 客户ID | 关联哪个客户 | | 跟进方式 | 电话、微信、上门、邮件(字典管理) | | 跟进内容 | 这次沟通了什么 | | 跟进时间 | 什么时候跟进的 | | 下次跟进时间 | **最重要的字段**,驱动下一次行动 | | 跟进结果 | 初步接触、需求明确、方案已发、等待反馈 | ### 下次跟进时间是 CRM 的引擎 每次跟进,销售必须填"下次跟进时间"。系统基于这个字段做两件事: 1. **待跟进提醒**:今天有哪些客户该跟进了,列表推给销售 2. **自动回收依据**:超过下次跟进时间还没新记录,触发回收 没有"下次跟进时间"的 CRM,销售跟完一次就忘了,客户就这么凉了。 ### 跟进后自动更新客户状态 跟进记录不是孤立的。一次"方案已发"的跟进,应该把客户状态从"跟进中"推进到"意向"。 ```java public void addFollowUp(FollowUp followUp) { // 1. 存跟进记录 followUpMapper.insert(followUp); // 2. 更新客户的最后跟进时间和下次跟进时间 customerMapper.updateFollowInfo( followUp.getCustomerId(), followUp.getFollowTime(), followUp.getNextFollowTime() ); // 3. 根据跟进结果推进客户状态 if ("INTENTION".equals(followUp.getResult())) { customerMapper.updateStatus(followUp.getCustomerId(), "INTENTION"); } } ``` --- ## 七、客户分配与转移 ### 分配的两种方式 | 方式 | 场景 | |------|------| | 主动领取 | 销售从公海自己挑客户领 | | 主管分配 | 主管把公海客户分配给指定销售 | 分配时的核心逻辑: ```java public void assign(Long customerId, Long ownerId) { Customer c = customerMapper.selectById(customerId); // 1. 检查是否在公海 if (c.getOwnerId() != null) { throw new BizException("客户不在公海,无法分配"); } // 2. 检查销售私海上限 int count = customerMapper.countByOwner(ownerId); if (count >= maxPrivateSize) { throw new BizException("该销售私海客户已达上限"); } // 3. 分配 + 设置保护期 customerMapper.assign(customerId, ownerId, now(), now().plusDays(3)); } ``` **私海上限很重要**。没有上限,销售会把公海客户全领完,其他人无客户可跟。 ### 离职转移 销售离职时,客户不能跟着走。两种处理: 1. **批量转给指定人**:主管把离职销售的客户转给新销售 2. **批量退回公海**:全部退回,重新分配 ```java public void transferOnResign(Long fromUserId, Long toUserId) { // 把离职销售的所有私海客户转给新人 customerMapper.transferOwner(fromUserId, toUserId); // 记录转移日志 transferLogMapper.log(fromUserId, toUserId, "离职转移"); } ``` --- ## 八、成交统计与看板 CRM 的最终目的是帮企业看清客户和成交情况。老板关心的几个数据: | 指标 | 说明 | |------|------| | 本月新增客户数 | 市场获客能力 | | 本月成交金额 | 销售转化能力 | | 客户转化率 | 线索→成交的漏斗 | | 销售排行榜 | 谁卖得好 | | 公海客户数 | 待分配的库存 | | 待跟进客户数 | 今天该做什么 | | 超期未跟进数 | 风险预警 | 这些数据不用每个都做大屏,先做一张首页看板就够了: ```text ┌─────────────┬─────────────┬─────────────┐ │ 本月新增客户 │ 本月成交金额 │ 转化率 │ │ 128 │ ¥356,000 │ 12.5% │ ├─────────────┼─────────────┼─────────────┤ │ 公海客户 │ 待跟进今日 │ 超期未跟进 │ │ 45 │ 23 │ 7 │ └─────────────┴─────────────┴─────────────┘ ``` 技术实现上,这些就是几个 `COUNT` / `SUM` 查询,配合 ECharts 渲染。如果用 Forge 的大屏能力,拖几个图表组件、接好接口就能出看板。 --- ## 九、用 Forge 怎么落地 讲完业务设计,说下技术选型。一个 CRM 涉及的能力和 Forge 的对应关系: | CRM 能力 | Forge 的能力 | |----------|-------------| | 客户/联系人/跟进记录页面 | 低代码 CRUD 页面搭建 | | 客户数据权限(销售看自己) | DataScope 拦截器,按 owner_id 过滤 | | 客户状态字典 | 字典管理 + DictSelect/DictTag | | 自动回收定时任务 | 定时任务插件(Quartz) | | 客户分配/转移 | 业务接口 + 操作日志 | | 成交统计看板 | AI 数据大屏 / 首页看板 | | 审批(大额折扣) | Flowable 工作流 | | 移动端跟进 | H5 端,手机录跟进记录 | 其中最省事的是**页面搭建**和**数据权限**: - 客户列表、搜索、表单:用低代码配置,不用手写 Vue - 数据权限:在 `sys_data_scope_config` 配一行,拦截器自动在 SQL 加 `owner_id = ?` - 客户状态:维护到字典,前端用 `DictTag` 渲染标签 真正需要手写的业务逻辑只有三块: 1. **公海领取/分配**:检查上限、设置保护期 2. **自动回收定时任务**:扫描超期客户、回公海 3. **跟进后状态联动**:更新客户最后跟进时间、推进状态 其他 80% 的工作(CRUD 页面、权限、字典、看板)用现成能力就能搞定。 --- ## 十、几个踩坑提醒 ### 1. 客户去重 同一家公司被不同销售录了两次,公海私海就乱了。录入时按公司名/统一社会信用代码查重,已存在则不允许重复录入,或走"认领"流程。 ### 2. 联系人不要塞进客户表 一个客户有多个联系人(采购、技术、老板),必须独立成表。塞进客户表会导致只能存一个联系人。 ### 3. 跟进记录不能删 跟进记录是销售行为的证据。即使客户战败,记录也要保留,用于复盘。加个"仅本人和主管可看"的权限就行,不要做物理删除。 ### 4. 回收阈值要可配 不同行业、不同客户级别的回收阈值不一样。别写死在代码里,做成字典或配置表,让业务方自己调。 ### 5. 公海不要做数据权限 公海是所有人都能看的,别给公海查询加数据权限拦截。私海受控、公海开放,这是规则。 --- ## 十一、总结:CRM 的本质不是存数据,是驱动行为 回到开头的问题:为什么很多 CRM 做成了高级通讯录? 因为只做了"存客户数据",没做**驱动销售行为**的机制。 一个能用的 CRM,核心是这三件事: | 机制 | 解决什么 | |------|----------| | 公海私海 | 客户归谁,防止囤积和抢单 | | 自动回收 | 逼着销售去跟进,不跟就收回 | | 下次跟进时间 | 驱动销售每天该干什么 | 数据权限保证客户资产安全,状态流转让销售知道客户到哪一步了,统计看板让老板看清全局。 把这几个机制做对,CRM 才不会沦为"高级通讯录"。 --- **体验完整的客户管理能力**: - 后台演示:http://www.dlforgelab.com:8084/forge/login (admin / 123456) - Gitee:https://gitee.com/ForgeLab/forge-admin - GitHub:https://github.com/yaomindong1996/forge-admin > 你做过的 CRM 踩过最大的坑是什么?公海私海、自动回收、还是数据权限?评论区聊聊。