做客户管理这件事,很多人一开始是拿Excel凑合的,客户少的时候没问题,等客户超过一两百个,你会发现漏跟进、记错人、翻聊天记录翻到眼瞎,数据散在微信、邮件、通话记录里,谁跟了什么单,完全靠脑子记。DeskcommCRM 这个项目就是我在这个背景下动手做的一套轻量级客户关系管理系统,核心思路是“围绕沟通管客户”,把每一次和客户的互动串成一条时间线,再配合跟进提醒、销售漏斗和基础权限隔离,让一个小团队不需要昂贵的CRM平台也能把客户盘清楚。这篇文章我会把整个项目的需求拆解、数据模型、核心功能实现、落地过程以及上线后踩过的坑完整写出来,适合正在用Excel管客户、准备自己搭一套内部CRM的开发者和业务负责人参考。
1. 项目定位与需求拆解
1.1 这个CRM到底解决什么问题
很多现成的CRM系统功能很全,但小团队用起来其实很痛苦。一是价格不便宜,按用户数收费的SaaS一年下来够请一个人了;二是流程太死板,标准产品逼着你去适配它的字段、审批流和权限模型;三是数据不在自己手里,客户信息放在第三方的服务器上,心里总觉得不踏实。DeskcommCRM从一开始就没打算做成大而全的体系,它的目标非常明确:把客户信息、沟通记录、跟进计划和成交状态四件事管好。
我经历过一个典型场景:销售在微信里和客户聊得挺好,转头开会一忙,三天没联系,客户被同行截胡;售后同事处理完一个问题,没有记录,下次客户再问类似问题,整个团队又从头开始查。这些问题的根源不是“人不够勤快”,而是“信息没有沉淀”。DeskcommCRM解决的就是沉淀问题,每一次沟通都留下痕迹,每一个客户都有唯一的档案,每一次跟进都有明确的下一步动作。这样不管谁来接手,都能在十分钟内搞清楚这个客户的全貌。
1.2 目标用户与适用场景
这个系统我设计之初主要面向三类使用者:销售、售后客服、团队管理员。销售关心的是哪些客户该跟进、我现在有哪些商机;售后客服关心的是历史沟通记录和问题处理进度;管理员关心的是每个人的工作量、客户池的健康度以及数据权限。
适用场景我归纳了一下,通常满足这几个条件的团队最适合用这类自建CRM:
- 客户量在几百到几千之间,没有到海量客户需要自动化营销的程度
- 业务流程以人工沟通为主,微信、电话、线下见面、邮件都有可能
- 团队规模在5到50人,不需要复杂的组织架构和跨部门审批流
- 有基础的技术能力,或者愿意花一点成本做二次开发
如果你的需求比这个再复杂,比如要做大规模邮件营销、复杂的报价审批、多币种订单管理,我建议还是评估成熟产品。自建CRM的边界要清楚,它不是替代Salesforce,而是替代“表格+微信群+个人备忘录用”的那套混乱状态。
1.3 核心功能范围
项目第一版定的功能范围很克制,一共五个模块:
- 客户档案:统一的客户信息库,支持标签、所属销售、自定义字段
- 沟通记录:电话、微信、邮件、面谈等多种渠道的互动时间线
- 跟进任务:针对客户或商机的待办提醒,支持截止时间和负责人
- 销售漏斗:按阶段统计商机数量、金额和转化情况
- 权限管理:基于角色的数据权限,销售只能看自己和本团队的数据,管理员看全部
这里有一条经验想分享:做内部系统,第一版功能一定要做减法。我见过很多项目一上来就想做公海池、批量导入、自动化工单,结果半年都上不了线。先解决最痛的“漏跟客户”和“信息不沉淀”,后面再迭代才有说服力。
2. 数据模型与架构设计
2.1 数据模型是整个系统的地基
做CRM最重要的不是界面,而是数据模型。数据模型定得不合理,后面每一个功能都会别扭。DeskcommCRM的核心实体有七个:用户、团队、客户、联系人、沟通记录、商机、跟进任务。一眼看上去好像跟别的CRM差不多,但我在设计时特别强调“客户”和“沟通记录”这两张表的关系。
常规管理系统里,客户信息通常是一张静态表,字段多到令人发指,什么“客户类型”“行业”“规模”“来源渠道”全都怼进去。我的做法是拆成两层:一层是客户主档,存相对稳定的属性,比如公司名称、所属行业、客户等级;另一层是动态行为,也就是沟通记录。前者是主数据,后者是事实数据。这样设计的好处是,你统计“这个月新增了多少客户”和“这个月发生了多少次有效沟通”可以走完全不同的数据路径,互不干扰。
客户主档的核心字段我做成这样:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 客户ID |
| customer_name | varchar(128) | 客户名称 |
| industry | varchar(64) | 所属行业 |
| customer_level | tinyint | 客户等级:1潜在 2普通 3重要 4核心 |
| owner_user_id | bigint | 负责销售ID |
| source | varchar(32) | 来源渠道:活动/转介绍/线上等 |
| status | tinyint | 状态:0潜在 1跟进中 2已成交 3已流失 |
| remark | text | 备注 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
沟通记录表则是另一个维度的设计,它更像流水账:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 记录ID |
| customer_id | bigint | 关联客户 |
| contact_id | bigint | 关联联系人 |
| user_id | bigint | 记录的归属人 |
| channel | tinyint | 沟通渠道:1电话 2微信 3邮件 4面谈 5其他 |
| content | text | 沟通内容摘要 |
| next_contact_at | datetime | 下次跟进时间 |
| created_at | datetime | 沟通时间 |
2.2 为什么给客户和联系人分开建模
很多初级设计会把联系人的姓名、电话、职位直接塞进客户表里,一个客户一行记录,客户有三个联系人就得搞三行,这是典型的误区。客户是一个组织,联系人是这个组织里具体的人,两者是一对多的关系。DeskcommCRM里联系人表单独存在,包含姓名、手机号、邮箱、职位、微信、备注、是否决策人这些字段,并且用customer_id关联回客户主档。
这个拆法在第一版貌似多造了一张表,但后面做沟通记录、跟进任务的时候真香。比如你要给某个客户的采购负责人打电话,你能在沟通记录里快速筛出“和谁聊的”;你要统计“核心决策人覆盖率”,一条SQL就能跑出来。如果一开始图省事,后面想拆数据就要写一堆迁移脚本,非常痛苦。
技术栈方面我最终选了前后端分离的结构:前端用Vue 3加Element Plus,后端用Java Spring Boot,数据库用MySQL 8,缓存和定时任务用Redis。这个选择不是因为它最潮,而是团队比较熟,招聘成本低,生态成熟。如果你只有几个人并且更熟悉Python,用FastAPI或Django也能做,核心是数据模型和业务流程,语言只是工具。数据库和缓存的部署我用了Docker Compose,写在一份配置里,测试机和生产环境直接拉起来,省去很多环境一致性的麻烦。
2.3 权限模型要一开始就设计好
权限是CRM里最容易被低估的东西。没有权限的CRM就像公司里的所有客户资料摊开在桌上,谁都能翻,销售肯定不愿意把客户信息填进去。DeskcommCRM的权限模型分了三个层级:超级管理员、团队主管、普通成员。普通成员只能查看和编辑“自己负责”的客户;团队主管可以看本团队所有客户;超级管理员看全部。
这个模型在代码里怎么落地?我的做法是每一张业务表都带有一个team_id字段和owner_user_id字段,查询时统一拼上数据权限条件。这里有一条实战经验:不要把权限判断分散在业务代码里到处写,建议放在统一的查询拦截层或者用一个权限工具类处理,否则很快会出现漏写条件导致的数据越权。后面我会专门讲一个因此踩的坑。
3. 核心功能模块实现与实操
3.1 客户信息统一管理
客户信息统一管理是CRM的第一步,大家最讨厌的是把过去Excel里的历史数据倒进去。我做了两种导入方式:一是手动新增,填写基础字段后直接保存;二是Excel批量导入。批量导入看起来简单,实际上要做三件事才能真正好用:模板下载、字段校验、重复检查。
字段校验里最容易出错的是手机号和邮箱格式,手机号我用了正则加运营商号段校验,邮箱用RFC格式的基础校验。重复检查我建议不要一上来就搞“完全匹配”,因为Excel里同一个公司可能被写过“某某科技有限公司”和“某某科技”两种名字。第一版可以先做精确匹配加人工确认,比如通过“客户名称完全一致”或者“手机号完全一致”来判重,命中后在页面上提示用户合并还是跳过。模糊匹配算法可以放到后面迭代,我用的是Levenshtein编辑距离加阈值判断,准确率一般,但能减轻不少人工筛选的工作量。
3.2 沟通记录与客户时间轴
沟通记录是整个DeskcommCRM最有价值的功能。我做的不是一个个孤立的“跟进记录表”,而是给每个客户生成一条完整的时间轴,所有渠道的沟通按时间倒序排,类似微信朋友圈的时间线展示。这样销售打开一个客户详情页,从上往下看,就知道这个故事讲到哪了。
时间轴的数据来源有三块:手动添加的沟通记录、系统自动生成的商机状态变更记录、以及跟进任务完成记录。也就是说,哪怕销售忘了手动记录,只要他更新了商机阶段或完成了任务,时间轴上也会有痕迹。数据汇总我用了一个简单的策略:统一查询三张表,在内存里合并排序,页面上按日期分组显示。客户量在几千时这个方案完全够用,不用一开始就搞复杂的聚合表。
添加沟通记录的表单我做得非常顺手:客户和联系人默认带出,渠道用下拉框,内容一个多行文本框,再加上“下次跟进时间”这个日期时间选择器。保存后同时触发两件事:更新客户主档的updated_at,如果设置了下次跟进时间,就往跟进任务表里插入一条任务。
3.3 跟进任务与提醒机制
跟进提醒是解决“漏跟客户”的关键。DeskcommCRM的任务表字段设计很简单:customer_id、title、due_at、assignee_user_id、status、completed_at。新建跟进任务时,默认负责人就是当前操作人,管理员可以把任务指派给别人。
提醒机制我最初想得很复杂,什么提醒一次没看到就升级给主管,后来被自己否了。MVP版本只做了两件事:第一,每天早上9点给每个用户推送当天到期的任务汇总,这个用Spring Boot自带的定时任务加Redis锁实现;第二,任务到期前2小时在站内通知中心发一条提醒。推送到企业的微信或钉钉群是后期加的,通过最普通的机器人Webhook就能实现,但第一版不做,因为站内通知已经可以解决80%的问题。
定时任务的代码我简单写一下思路,不用上复杂的分布式调度框架,单机部署加一个Redis锁足够:
@Scheduled(cron = "0 0 9 * * ?") public void dailyTaskRemind() { String lockKey = "task:dailyRemind:lock"; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(locked)) { return; } List<TaskVO> tasks = taskService.findTasksDueToday(); Map<Long, List<TaskVO>> groupByUser = tasks.stream() .collect(Collectors.groupingBy(TaskVO::getAssigneeUserId)); groupByUser.forEach((userId, list) -> { notifyService.sendDailyDigest(userId, list); }); }Redis锁的作用是防止同一台机器部署多个实例或者重复触发导致发重复通知。虽然我们暂时只有一台服务器,但这个习惯要养成,因为以后随时可能扩容。
3.4 销售漏斗与基础报表
销售漏斗我实现得比较轻:商机表里有stage字段,取值从“初次沟通”“方案确认”“商务谈判”到“赢单”“输单”。列表页按Stage分组展示,每张卡片显示商机名称、金额、所属客户和预计成交日期,右上角带一个数字角标。主管可以拖拽卡片来改变阶段,实际上就是一个状态流转的图形化入口。
统计报表这块我用了一个折中的方案:实时查询主库,而不是引入独立的数仓。原因是当前数据量太小,实时查询完全扛得住,引入大数据组件纯属过度设计。报表包括四张图:新增客户趋势、商机阶段分布、各销售业绩排名、沟通数量排行。前两张用ECharts的折线图和漏斗图,后两张用普通的柱状图和表格。
我写SQL时踩过一个索引的坑。统计销售排名时,一条查询要对orders表做groupBy,刚开始amount字段类型是decimal(10,2),索引没建,跑一万条数据就要一两秒。后来给owner_user_id和created_at加了联合索引,查询直接降到几十毫秒。这个经验特别典型,做报表不要上来就搞预聚合,先把索引优化做了再说,大多数情况下索引就够用了。
4. 从0到1落地实录
4.1 环境准备与部署
项目环境我用Docker Compose搭建,目录结构大概长这样:
deskcomm-crm/ ├── docker-compose.yml ├── backend/ │ ├── pom.xml │ └── src/ ├── frontend/ │ ├── package.json │ └── src/ └── sql/ └── init.sqldocker-compose.yml里我定义了MySQL、Redis、backend、nginx四个服务。开发阶段后端用本地IDEA启动,前端用Vite的代理转发请求;生产环境统一用Docker Compose启动,nginx托管前端静态文件并反向代理后端接口。
这里分享一个配置细节:MySQL容器一定要挂载数据卷,否则容器一重建数据库全没了。我会在docker-compose.yml里专门指定:
mysql: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: deskcomm_crm volumes: - ./data/mysql:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro ports: - "3306:3306"第一次启动时,init.sql会自动建库建表,省去手动导入的麻烦。用环境变量文件管理密码而不是直接写死在yml里,这个习惯也要养成,防止配置不小心被推到公开仓库。
4.2 核心接口设计与示例
前后端对接时,接口设计要尽量直观。DeskcommCRM的核心接口我列几个最有代表性的:
| 接口 | 方法 | 说明 |
|---|---|---|
| /api/customers | GET | 客户列表,支持分页和搜索 |
| /api/customers/{id} | GET | 客户详情,包含时间轴 |
| /api/customers | POST | 新建客户 |
| /api/interactions | POST | 新增沟通记录 |
| /api/tasks/remind-today | GET | 今日到期任务 |
| /api/opportunities/{id}/stage | PUT | 更新商机阶段 |
| /api/reports/sales-ranking | GET | 销售业绩排名 |
以前端新增一条沟通记录为例,请求体长这样:
{ "customerId": 1024, "contactId": 58, "channel": 2, "content": "与李经理确认了报价细节,对方需要下周内给最终答复", "nextContactAt": "2024-03-18 10:00:00" }后端收到请求后的处理顺序是:先校验参数,再插入沟通记录表,然后更新客户表的updated_at,紧接着判断nextContactAt是否非空,非空则向任务表插一条待办任务,最后给当前用户返回操作成功的消息。整个过程在同一个事务里执行,避免出现沟通记录保存成功但任务没生成的数据不一致情况。
4.3 前端交互的细节处理
前端有几个细节我特别想分享。
客户列表页的搜索框,一定要做防抖,否则每次按下键盘都会发一个请求,后端接口虽然扛得住,但是体验很糟糕。我的做法是用300毫秒防抖,停止输入后再搜索,实测下来流畅很多。
时间轴的展示,建议用相对时间而不是绝对时间。页面顶部显示“今天 14:30”,而不是“2024-03-01 14:30”。相对时间看起来直观,用户扫一眼就知道最近发生了什么。实现方式很简单,后端返回完整时间戳,前端根据当前时间计算相对偏移并显示,鼠标悬停再显示完整日期。
商机卡片拖拽的时候,有一个容易忽略的问题:拖拽结束后要立即显示乐观的UI反馈,同时后台异步发送更新请求。如果失败了,再回滚到原来的状态并弹出错误提示。不要等到接口完全返回才更新UI,那样会有明显的卡顿感。
4.4 报表页面的性能优化
前面提过报表的索引优化是一次关键性能提升。这里补充一个场景:销售排名接口最初是查询所有订单在代码里做合计,后来改为一条SQL直接分组聚合,代码少了一大截,速度也快了。类似的,沟通数量排行也改成直接在沟通记录表上按user_id分组计数,不再查客户表。
另外一个优化是报表缓存。我用了Redis的缓存,key是报表名称加日期,过期时间设为10分钟。这样即使有多个管理员同时打开仪表盘,也不会每次都打到数据库。缓存更新的策略是懒加载,有新的访问时发现缓存过期就重新查询回填。对于第一版的CRM,这种方式简单实用,不用处理复杂的缓存一致性。
5. 上线后踩过的坑与排查技巧
5.1 时区问题导致提醒不准
上线后出现过一次用户投诉:早上设置的第二天下班后提醒,结果当天半夜就推送了。排查发现是因为后端服务器时区是UTC,数据库存的是UTC时间,Redis里存的是时间戳,前端显示的是北京时间,三个地方各玩各的。
这个问题我最终的解决方案是:全链路统一使用UTC存储,仅在接口返回时按用户时区格式化。数据库连接参数里加上serverTimezone=UTC,接口层用FastJson配置日期格式化时指定Asia/Shanghai。这样数据库里存的是标准时间,展示层按业务地区转换,不再出现歧义。
5.2 客户重复合并的坑
Excel导入之后,重复客户特别多。最开始我的合并功能做得太激进,点击“自动合并”之后直接保留最近更新的记录,把另一条删除。结果有客户的两个联系人其实是两个人,合并后联系人表也乱了,数据损失惨重。
后来我调整了策略:合并之前先对比客户名称、联系人数量、商机数量,凡是关联数据不一致的,都要求人工处理。合并操作做成两步,第一“预合并”只是把其中一条的负责人和沟通记录重定向,第二“真正合并”才删除冗余客户主档,并且在操作日志里留痕。这个流程保守,但是安全。
5.3 权限漏写导致越权
这是我最后悔的注意点之一。客户列表加了权限过滤,但是客户详情接口里有个“关联商机列表”子接口,忘记加数据权限条件,结果普通销售可以遍历商机ID看到所有客户的商机数据。这个问题不是功能没做完,而是权限判断没有统一处理。
修完这个Bug之后,我做了一次全量代码审查,把每个需要数据权限的查询都列了一张清单,强制要求Mapper层必须有team_id和owner_user_id的条件。把这个判断抽象成一个公共方法后,新增查询都会默认调用,才彻底堵住这个口子。
5.4 大量导入导致接口卡死
Excel导入功能一开始是同步执行的,一个5000行的文件导入,后端要逐行校验、逐行插入,接口可能十几秒都不返回。这不是好体验。后来我改成异步导入:前端先上传文件,后端立刻返回“导入中”状态,后台线程池处理导入,用户通过任务ID轮询进度。虽然代码量多了一点,但体验好很多,用户不需要一直转菊花。
异步导入的时候还要注意事务边界。我之前是把整个导入当作一个大事务,一有错误就全部回滚,后来发现100行数据里有一行格式错误,用户就得全部重来。改成按批提交,每500行一个事务,错误行记录在错误报告里,用户下载错误报告后修正再重新导入。这样才能做到部分成功,部分失败。
5.5 常见问题速查表
| 问题现象 | 排查方向 | 解决建议 |
|---|---|---|
| 通知没发出去 | 检查Redis锁和定时任务日志 | 确认单实例还是多实例,锁时间不能太短 |
| 页面时间显示差8小时 | 检查数据库连接时区参数 | 统一用UTC存库,返回时格式化 |
| 导入文件报内存溢出 | 解析Excel时一次性加载全部 | 使用SAX方式逐行读取,限制单次导入行数 |
| 销售漏斗金额对不上 | 检查商机金额更新的并发问题 | 使用乐观锁version字段,更新时带上 |
| 客户列表越权 | 审查Mapper查询条件 | 统一数据权限过滤,禁止裸SQL |
6. 给想自建CRM的人一些实在建议
DeskcommCRM做到这个版本,我心里最大的感受是:内部工具的价值不在于功能数量,而在于每天是不是真有人用。功能再炫,销售觉得录入成本高、查询不顺手,最后还是回到Excel和微信上去。所以做完第一版后,我花了很多时间观察团队的使用习惯,把“最常用的三个页面做到打开即用”作为优化目标,而不是急着增加新功能。
如果你也想做一个类似的系统,我建议先从一张客户表、一张沟通记录表、一个任务提醒开始,把它们做扎实,再把漏斗和报表一点点加上去。技术选型不要追新,选团队最熟的那套,框架只是表达业务逻辑的手段。权限模型一定要在一开始就设计好,别等数据多了再补,那时候改造成本会让你怀疑人生。
最后分享一个小技巧:上线后我每天都会看操作日志,尤其是记录下哪个页面停留时间最长、哪个按钮都没人点。没人点的功能就该砍掉,页面里藏着没人用的入口,比没有功能更伤体验。CRM这种数据密集的系统,简洁就是最好的用户体验,保持克制,持续迭代,比一开始做大量预测性功能靠谱得多。