掘金-从零搭一个客户管理系统CRM.md 15 KB

别把 CRM 做成高级通讯录:从零设计一个能用的客户管理系统

很多团队做 CRM,最后做成了"客户信息录入表"。这篇不讲技术框架,先讲清楚 CRM 的业务模型该怎么设计,再说怎么落地。

做过客户管理系统的开发者,大概率遇到过这种对话:

业务方:"我们要一个 CRM。" 开发:"好的,客户表、跟进记录表、成交记录表,三天搭完。" 上线一个月后,业务方:"这个 CRM 没人用,销售还是用 Excel。"

为什么?因为大部分 CRM 做成了"高级通讯录"——只能录客户信息,不能驱动销售行为。

真正能用的 CRM,核心不是"存客户数据",而是解决三个业务问题:

  1. 客户归谁(公海私海机制)
  2. 客户该跟进了吗(跟进节奏与回收)
  3. 客户成交了吗(状态流转与统计)

这篇就把这三个问题讲透,再给出技术落地方案。


一、CRM 的核心数据模型

先别急着建表,先想清楚 CRM 里有哪些核心实体。

客户(Customer)
  ├── 联系人(Contact)      一个客户多个联系人
  ├── 跟进记录(FollowUp)   一个客户多条跟进
  ├── 成交记录(Deal)       一个客户多笔成交
  └── 状态变更日志(StatusLog)
实体 核心字段 说明
客户 名称、来源、行业、级别、状态、负责人ID、所属部门 CRM 的主体
联系人 姓名、电话、职位、客户ID 一个客户多个联系人
跟进记录 客户ID、跟进方式、跟进内容、跟进时间、下次跟进时间 驱动销售行为的核心
成交记录 客户ID、成交金额、成交时间、订单号 统计用

这张表看着简单,但很多团队在第一步就埋了坑:把所有信息塞进一张客户表。客户表 30 个字段,连联系人电话、最近一次跟进内容都塞进去。结果是:一个客户多个联系人时没法存,跟进记录无法追溯。

原则:客户表只存客户本身的属性,联系人和跟进记录独立成表。


二、公海私海:CRM 最核心的业务设计

这是区分"高级通讯录"和"真正 CRM"的分水岭。

什么是公海私海?

  • 私海:分配给某个销售负责的客户,只有该销售能看到和跟进
  • 公海:没有负责人的客户池,所有销售可以领取

为什么需要这个机制?

没有公海私海的 CRM 会出现两个极端:

  1. 所有客户所有人都能看 → 销售互相抢单、客户被重复骚扰
  2. 客户分配后永久归属 → 销售囤积客户不跟进、离职带走客户

公海私海的本质是:客户是公司的资产,不是个人的资产。公司可以把客户分配给销售,也可以收回。

数据库怎么设计?

客户表加三个字段:

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 里):

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)
  )

四、客户状态流转:线索到成交的完整链路

客户不是静态的,会从"线索"一路走到"成交"或"流失"。状态流转设计不好,销售就不知道该干什么。

标准状态流转

线索 → 跟进中 → 意向 → 谈判 → 成交
                ↘ 流失
                ↘ 战败
状态 含义 销售该做什么
线索 刚录入,未确认有效性 联系客户,判断意向
跟进中 已联系,需求待挖掘 持续跟进,了解需求
意向 有明确需求 提供方案、报价
谈判 方案已报,在推进 谈条件、促成交
成交 已签约 交付、维护
流失 客户主动放弃 分析原因
战败 跟进失败 复盘、归档

状态不能随便跳

状态流转要有约束。不能从"线索"直接跳到"成交"——中间至少要经过"跟进中"。

建议用状态机管理,每次状态变更记录日志:

-- 状态变更日志
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 (...),业务代码完全不用管。

公海客户的查询单独配一个方法,不加数据权限:

// 私海客户列表(受数据权限控制)
List<Customer> selectPage(@Param("dto") CustomerQueryDTO dto);

// 公海客户列表(所有人可看,不拦截)
@IgnoreDataScope  // 或不配 data_scope_config
List<Customer> selectPublicPool(@Param("dto") CustomerQueryDTO dto);

六、跟进记录:驱动销售行为的核心

很多人把跟进记录做成"备注"——一个文本框,随便填。这是错的。

跟进记录是 CRM 里最重要的业务实体,它驱动销售行为,也决定客户是否被回收。

跟进记录该有哪些字段?

字段 说明
客户ID 关联哪个客户
跟进方式 电话、微信、上门、邮件(字典管理)
跟进内容 这次沟通了什么
跟进时间 什么时候跟进的
下次跟进时间 最重要的字段,驱动下一次行动
跟进结果 初步接触、需求明确、方案已发、等待反馈

下次跟进时间是 CRM 的引擎

每次跟进,销售必须填"下次跟进时间"。系统基于这个字段做两件事:

  1. 待跟进提醒:今天有哪些客户该跟进了,列表推给销售
  2. 自动回收依据:超过下次跟进时间还没新记录,触发回收

没有"下次跟进时间"的 CRM,销售跟完一次就忘了,客户就这么凉了。

跟进后自动更新客户状态

跟进记录不是孤立的。一次"方案已发"的跟进,应该把客户状态从"跟进中"推进到"意向"。

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");
    }
}

七、客户分配与转移

分配的两种方式

方式 场景
主动领取 销售从公海自己挑客户领
主管分配 主管把公海客户分配给指定销售

分配时的核心逻辑:

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. 批量退回公海:全部退回,重新分配
public void transferOnResign(Long fromUserId, Long toUserId) {
    // 把离职销售的所有私海客户转给新人
    customerMapper.transferOwner(fromUserId, toUserId);
    // 记录转移日志
    transferLogMapper.log(fromUserId, toUserId, "离职转移");
}

八、成交统计与看板

CRM 的最终目的是帮企业看清客户和成交情况。老板关心的几个数据:

指标 说明
本月新增客户数 市场获客能力
本月成交金额 销售转化能力
客户转化率 线索→成交的漏斗
销售排行榜 谁卖得好
公海客户数 待分配的库存
待跟进客户数 今天该做什么
超期未跟进数 风险预警

这些数据不用每个都做大屏,先做一张首页看板就够了:

┌─────────────┬─────────────┬─────────────┐
│ 本月新增客户  │ 本月成交金额  │ 转化率       │
│    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 才不会沦为"高级通讯录"。


体验完整的客户管理能力

你做过的 CRM 踩过最大的坑是什么?公海私海、自动回收、还是数据权限?评论区聊聊。