1. 先说清楚:DeskcommCRM 到底解决什么问题
我第一次接触 DeskcommCRM 这个项目的时候,团队里其实已经有一套“用 Excel 管理客户”的流程了。听起来很离谱对吧?但小团队、销售型公司、初创项目,这类场景里 Excel 管理客户反而是常态。问题在于:客户一多,Excel 就撑不住了。重复客户记录、跟进到哪一步没有统一口径、销售之间互相撞单、管理层想看个漏斗数据还得等助理手工汇总。我接手 DeskcommCRM 的初衷,就是把这些乱七八糟的“人肉 CRM 流程”换成一套能真正跑起来、还不至于过度设计的系统。
DeskcommCRM 的定位很简单:一套面向中小型销售团队和客户成功团队的桌面端通信型客户管理系统。它不是一个什么都能干的 SaaS 巨无霸,而是把客户资料、线索跟进、销售过程、数据统计这几件最核心的事做到位。它能解决的核心问题有三个:一是客户信息不再散落在每个人的电脑里,而是统一沉淀在系统里;二是每个销售每天应该跟进谁、跟到哪一步、下一步该做什么,系统能给你一个清晰的提示;三是管理者不需要再等周报月报,打开仪表盘就能看到线索到签约的转化情况。
这篇内容适合谁看?如果你正准备做一套企业内部管理系统,不管是 CRM、工单系统还是其他带权限、带审批的业务系统,这篇文章里关于数据模型、权限设计、统计口径、部署实践的思路都可以直接借鉴。如果你只是选型想了解自研 CRM 要踩哪些坑,这篇文章也能帮你提前排排雷。
2. 整体设计思路与技术选型拆解
2.1 功能模块划分:先把范围边界切清楚
做任何系统,第一步不是写代码,而是把边界切清楚。DeskcommCRM 第一版我规划了五个核心模块:
- 客户管理:客户的基本信息、联系人、所属销售、客户来源、分类标签。
- 线索管理:还没转化为客户的潜在名单,需要录入、分配、跟进、转化。
- 跟进记录:每个客户每条跟进的时间、方式、内容、下一步计划。
- 商机管理:把一个客户从“初步接触”到“赢单/输单”的过程拉成一条看得见的管线。
- 数据报表:销售漏斗、个人业绩看板、客户来源分析、跟进效率统计。
这个划分看起来不复杂,但有一个很重要的原则:单模块职责闭环,模块之间只通过明确的数据关系连接。比如线索“转化”为客户,本质不是复制一份数据,而是把线索状态改成“已转化”,同时创建一条关联的客户记录,并且保留线索和客户之间的双向索引。这样做的理由是:你永远能追溯一个客户到底是从哪条线索来的,而不是一脸懵地看到一堆“来路不明”的客户。
还有一个容易被忽略的模块是“系统管理”。权限、角色、字典、操作日志、数据导入导出,这些非业务功能占了整个项目大概三分之一的工作量。别小看它,很多 CRM 项目做到一半烂尾,就是低估了这一块。
2.2 技术栈选型:为什么我最后选了这套组合
技术选型没有绝对的标准答案,只有“适不适合你的团队”和“适不适合你的部署环境”。DeskcommCRM 的技术栈我定的是:后端 Java Spring Boot + MyBatis-Plus,数据库 MySQL 8.0,缓存 Redis,前端 Vue 3 + Element Plus。
选 Java Spring Boot 的理由很现实:企业内部系统最怕的是“人走了代码没人能接”。Spring Boot 生态大、招人容易、资料多,哪怕几年后维护的人换了,也不至于对着代码抓瞎。相比 Node.js 或 Python 后端,Java 在这种业务系统里的稳定性和事务处理能力也更让人放心。MyBatis-Plus 则是为了效率——它能在不影响 SQL 灵活性的前提下,把单表 CRUD 的代码量砍掉一大半。
MySQL 做主力存储没什么好争议的,CRM 的数据量级通常在千万以内,MySQL 完全扛得住。Redis 主要用来做三件事:登录令牌存储、热点数据缓存(比如部门架构、字典项)、并发场景下的分布式锁。
前端选 Vue 3 + Element Plus 主要是考虑后台管理界面的开发效率。Element Plus 的表单、表格、弹窗组件很成熟,能让我们把更多精力放在业务逻辑上,而不是自己造轮子。
提示:如果你的团队是纯前端团队,后端也可以用 Node.js 或直接上低代码平台。但我个人经验是,带事务、带复杂权限的业务系统,用 Spring Boot 这类成熟后端框架踩坑成本最低。
2.3 数据模型设计:一套能撑住后续扩展的表结构
数据模型是整个 CRM 的心脏。我见过太多项目因为表结构设计太随意,做到一半发现加字段加不进去、统计查不出来、权限限制不住。DeskcommCRM 的核心表设计,我遵循了几个原则。
第一,客户表(customer)只存客户本身的属性,不掺和业务状态。客户名称、行业、规模、地址、来源、所属销售、创建时间、更新时间。有人喜欢把“跟进状态”也放在客户表里,我强烈不建议这么做。因为一个客户可能有多个商机,商机和商机之间的状态是不一样的。把所有状态都堆在客户表上,最后只能给你数据统计埋雷。
第二,跟进记录表(follow_up_record)一定要冗余客户名和销售名。虽然通过外键关联能查到,但在列表页和导出报表时,如果没有冗余字段,每一个列表都要 join 三四张表,性能会很难看。冗余字段的代价是数据一致性需要自己保证,但在这个场景下,收益远大于成本。
第三,商机表(opportunity)里放了一个 stage 字段,用字典值表示阶段,而不是直接存中文。比如 1 表示初步接触,2 表示需求确认,3 表示方案报价,4 表示谈判,5 表示赢单,0 表示输单。这样做的好处是:前端可以配置颜色和文案,后端统计漏斗时可以直接按数字分组,不用处理中文字符串的匹配问题。
第四,所有核心表都加了 is_deleted 字段做逻辑删除。这看起来是个老生常谈,但真的很多项目第一版没做,后来要找回误删数据的时候傻眼了。逻辑删除配合 MyBatis-Plus 的 @TableLogic 注解,实现成本极低,千万别省。
我把客户表结构简化贴出来,供参考:
CREATE TABLE `customer` ( `id` bigint NOT NULL AUTO_INCREMENT, `customer_name` varchar(200) NOT NULL COMMENT '客户名称', `industry` varchar(100) DEFAULT NULL COMMENT '所属行业', `customer_source` varchar(50) DEFAULT NULL COMMENT '客户来源', `owner_user_id` bigint DEFAULT NULL COMMENT '负责人用户ID', `owner_dept_id` bigint DEFAULT NULL COMMENT '负责人所属部门ID(冗余)', `phone` varchar(50) DEFAULT NULL COMMENT '联系电话', `address` varchar(500) DEFAULT NULL COMMENT '地址', `remark` text COMMENT '备注', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `is_deleted` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_owner_user_id` (`owner_user_id`), KEY `idx_customer_name` (`customer_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户表';这里我把 owner_dept_id 单独冗余出来了。这不算规范化的设计,但在做数据权限控制时非常有用——它可以避免每次判断“某个部门的销售能不能看这个客户”都要连到用户表再连到部门表,性能提升是实打实的。
3. 核心模块落地实操
3.1 客户录入与线索去重:不重复,才有可信度
CRM 系统最怕什么?不是功能少,是数据烂。客户录入了两遍、三遍,同一个公司被不同的销售各抢一遍,最后系统里看起来客户量很大,实际上真实客户数量少得可怜,所有统计数字都是虚的。所以客户管理的第一个硬功能,就是去重。
DeskcommCRM 的去重策略分两层。
第一层是录入时实时校验。在客户名称输入框失焦时,前端把客户名称传给后端,后端使用精确匹配和关键字匹配双重判断。精确匹配就是完全同名直接提示“已有同名客户”;关键字匹配则稍微复杂一点,比如“北京字节跳动科技有限公司”和“字节跳动”这种,靠简单 SQL 的 LIKE 是查不出来的,我用的方案是维护一个模糊匹配规则,把一些常见后缀(“有限公司”、“股份有限公司”、“科技”、“技术”)做归一化处理后,再按归一化后的名称查重。
第二层是导入时的批量去重。Excel 批量导入的场景,逐条提示是不现实的,我的做法是:上传后先在后台异步跑一遍去重任务,生成一个“疑似重复客户列表”,前端展示给用户勾选——哪些是新增,哪些是跳过。这一步看着简单,但它决定了导入功能能不能被用户真正用起来,而不是导一次骂一次。
3.2 跟进记录与时间线设计:别把客户历史变成一条流水账
跟进记录是 CRM 里使用频率最高的功能,也是体验最容易被做烂的功能。如果你的系统里跟进记录只是按时间从上往下排的一堆文字,销售根本不想用,因为他很难在一屏之内快速搞明白“这个客户之前聊了什么、卡在哪了”。
DeskcommCRM 的跟进记录做成了“时间线”样式。每条记录包含跟进方式(电话、微信、拜访、邮件)、跟进内容、下次跟进时间、下一步计划。时间线中间有一条主轴线,客户的关键动作(比如“创建商机”、“报价发送”、“赢单”)会以特殊标记插入到时间线里。这样销售打开客户详情页,从上往下滑动,就能像看故事一样看到这个客户的完整生命周期。
这里有个技术细节值得一说:跟进记录的时间线查询,如果只是单纯按时间查询 follow_up_record 表,那客户“创建商机”这类非跟进行为就没法出现在同一条时间线里。我最终用的是"类型加时间"的混合查询——把跟进记录、状态变更记录、文件上传记录合并成一个视图数据源,再在内存里按时间排序分页。数据量不大时这么干完全没问题,但数据量超过十万条后,建议在数据库层做 union 查询,或者引入搜索中间件,避免每次都把数据拉到内存。
还有一个非常容易被忽略的点:跟进计划。每一条跟进记录里都有一个“下次跟进时间”,但这个计划如果只是展示出来,用户是不会自觉遵守的。DeskcommCRM 做了一个“今天待跟进”的首页清单,每天上班打开系统,销售第一眼看到的就是今天该跟进的客户列表。这一招非常管用,它把“计划”变成了“任务”,使用率立刻上来了。
3.3 报表与销售漏斗:统计口径不对,报表就是害人
报表模块是整个项目里最考验逻辑严谨性的地方。同样是“本月新增客户数”,你可以理解为“本月创建时间在当月的客户数”,也可以理解为“本月首次成为客户的时间在当月的客户数”。两种口径差一点点,结果可能差很多。
DeskcommCRM 的销售漏斗图,我花了不少心思。漏斗的阶段和商机表的 stage 字段对应,统计逻辑是:对每个商机取其当前所处阶段,然后从最高阶段到最低阶段,分别计数。比如“方案报价”这一层的数量,就是所有当前处于方案报价及之后阶段的商机数量。为什么要“及之后”?因为一个已经赢单的商机,一定曾经经历过方案报价这个阶段。如果你只统计当前恰好处于某阶段的商机,漏斗越往下数字越小得离谱,而且会误导管理者以为“报价后成交率很低”,实际上只是大家没有及时更新阶段而已。
这个口径问题,是所有做 CRM 报表的人都要小心的地方。我建议在设计报表时,一定要和业务负责人确认清楚每一个指标的定义,并且把定义写在系统帮助文档里。否则系统做出来后,每个人都按自己的理解解读报表,争论不休,那就不是工具,而是制造矛盾的机器了。
3.4 权限模型:销售不该看到别人的客户
CRM 的权限模型,单说“角色”是不够的。DeskcommCRM 用的是“角色 + 数据范围”的双层机制。
角色管的是“能做什么”,比如销售顾问能新增客户、编辑自己的客户、查看报表;销售主管能分配客户、查看本部门所有客户;管理员能配置系统、操作数据字典。
数据范围管的是“能看到哪些数据”,我把它划分成四档:仅本人、本部门、本部门及下属部门、全部。这个数据范围不是一个开关,而是挂在角色上的一个数据字段。用户登录后,后端会把当前用户的数据范围计算出来,注入到每次查询的过滤条件里。
实现上,我用的是 MyBatis-Plus 的拦截器机制。自定义一个 DataScopeInterceptor,在执行 select 语句时,自动根据当前登录人的数据范围装配 SQL 条件。比如“仅本人”就追加AND owner_user_id = 当前用户ID,“本部门”就追加AND owner_dept_id = 当前部门ID。
注意:数据权限的过滤必须放在后端 SQL 层完成,绝对不能依赖前端传参、不能依赖前端隐藏按钮。否则用户直接调用接口就能越权访问数据,这是严重的安全漏洞。
还遇到过一个实际问题:客户被分配给了离职销售的怎么办?我的方案是设计了一个“客户继承”逻辑——销售离职后,管理员可以把该销售名下所有客户批量转移给其他同事,转移的同时保留历史跟进记录,并且会在时间线里打一条系统记录:“客户负责人由张三变更为李四”。这个功能当时不算起眼,但后来人事变动频繁时,大家都庆幸有它。
4. 部署、性能优化与安全加固
4.1 部署方案:一套脚本搞定测试和生产
DeskcommCRM 的部署,我用的是 Docker Compose。一个项目包含四个容器:后端应用、前端 Nginx、MySQL、Redis。
之所以没有上 Kubernetes,是因为团队规模摆在那里,一套 K8s 集群光维护成本就够喝一壶了。Docker Compose 在单机部署场景下完全够用,配置文件清晰,回滚也简单。
关键的部署细节有几个。第一,MySQL 数据目录要挂载到宿主机,否则容器一删数据就全没了。第二,后端日志也要持久化,排查问题的时候没有日志等于瞎猜。第三,Nginx 里要配好前端静态文件的缓存策略,否则每次发版后用户浏览器还带着老版本,明明功能改好了用户却看不到。
4.2 查询性能优化:列表页从 8 秒到 300 毫秒
CRM 系统性能最大的瓶颈往往不是高并发,而是“列表页越查越慢”。客户列表、跟进记录列表,这些页面翻到后面几页时,数据库要扫描越来越多的数据,响应时间直线上升。
我在 DeskcommCRM 里做了三件比较有效的优化。
第一,MyBatis-Plus 的分页插件必须配好,并且确认 count 查询走了索引。这是最基础的一步,但很多人忽略了 count 的性能,表数据一多就崩。
第二,给常用的查询条件建联合索引。比如客户列表最常见的过滤条件是“所属销售 + 创建时间”,就建立一个(owner_user_id, created_at)的联合索引。字段顺序很讲究:等值判断的字段放前面,范围判断的字段放后面。这个顺序错了,索引效率会大打折扣。
第三,列表页的“高级筛选”不做全字段 like。有人把客户名称、联系电话、地址、备注四个字段全做 like,一旦输入一个通用词(比如“科技”),数据库直接全表扫描。我的方案是:名称和电话走索引查询,地址和备注只允许在点击“搜索”时再做包含匹配,而且匹配时限制最多返回前 2000 条。这样做虽然损失了一些灵活性,但保住了核心体验。
实际操作中还有个很典型的案例:跟进记录列表默认只查询“最近 6 个月”的数据,并且加了一个时间范围控件,让用户可以自己选更长的时间段。为什么要默认限制?因为大部分用户翻历史记录最多翻到半年前,限制时间范围能让索引效率提升一大截,用户几乎感知不到“数据变少”。
4.3 数据安全与备份:敢不敢拍胸脯说数据不会丢
做业务系统,数据安全永远是红线。我在这块踩过一个让我印象深刻的坑:有一版部署脚本,把 MySQL 的数据目录挂载错了,导致容器重启后所有数据都消失了。幸好是在测试环境,如果是生产环境,那就不是事故,是灾难了。
所以后来我只用两种备份方案,并且每一条都验证过“确实能恢复”才敢上线。第一是 MySQL 每日凌晨全量备份,用 mysqldump 导出 SQL 文件,保留最近 14 天的备份;第二是每月一次全量备份直接同步到对象存储,防止服务器磁盘出问题。这里我想强调一句:备份没用,恢复才有用。我见过太多人配了备份任务,但从没跑过恢复演练,真出问题时备份文件是坏的,或者恢复步骤忘了,最后还是抓瞎。我自己的习惯是每个季度做一次从备份恢复到新环境的演练,确认数据完整、服务能起、核心功能可用。
5. 踩坑实录:常见问题与排查技巧
5.1 Excel 导入乱码和丢数据的处理
Excel 导入是 CRM 系统最基本的入口之一,但我敢说十个系统有八个在这上面出过问题。最早的坑是编码问题:用户上传 CSV 文件,如果文件是 GBK 编码,而我们按 UTF-8 读取,中文全部变成乱码。
我的处理思路是:先读取文件的前几个字节判断编码,有 BOM 的就按 BOM 识别,没有 BOM 的文字则通过解码成功率来判断是 UTF-8 还是 GBK,最后统一转成 UTF-8 再做后续处理。另外,不要直接用 POI 的 DataFormatter 对单元格进行原样读取,要区分清楚“字符串”和“数字”——客户电话这种字段,如果用户输入的是超长数字,Excel 内部会将其转成科学计数法或数字格式,直接读出来会丢失精度,必须在读取时统一转成字符串并去除小数点。
我还在导入功能里加了一个“先预览后提交”的环节:上传文件后,系统解析前几条数据,在前端展示成表格,让用户确认格式对不对,避免全部导入后才发现这一批数据全是乱的。这个功能虽然多花了一点开发时间,但大大降低了用户对导入功能的怨气。
5.2 并发放慢和死锁问题:不是每一次都真的“并发”了
有一次用户反馈“保存跟进记录时经常转圈很久”,查看日志发现是数据库死锁。死锁的原因说起来有点搞笑——销售在保存跟进记录时,系统会自动更新客户表的 updated_at 字段,同时也可能自动触发客户表“最近跟进时间”字段的更新。两个请求同时更新同一个客户的不同记录时,锁的加锁顺序不一致,就死锁了。
解决思路有三个。第一,所有更新客户表的 SQL,都统一先通过客户 id 加锁,并且加锁顺序保持一致。第二,对“最近跟进时间”这类高频更新字段,改成一个异步任务去更新,不在请求主链路里同步执行。第三,数据库设置合理的等待超时时间,让死锁快速暴露而不是无限挂起。
这里我想多说一句:面对死锁,不要一上来就加“全局锁”或“串行化”,那样确实能解决问题,但会把系统的并发能力直接砍到地板。真正要做的,是先理清锁的获取顺序,因为死锁的本质就是锁的环形等待。
5.3 时区问题:凌晨 0 点看到的统计数字对不上
第一次部署到服务器后,发现报表的“今日新增”数据和实际对不上。排查到最后发现是时区问题:服务器用的是 UTC 时间,而业务上大家都是北京时间。数据库里存的时间看起来正常,但统计 SQL 里用DATE_FORMAT(created_at, '%Y-%m-%d')分组时,服务器的时区影响了日期边界。
解决方式很简单:数据库连接的 URL 参数里设置serverTimezone=Asia/Shanghai,同时 JVM 启动参数加上-Duser.timezone=Asia/Shanghai,然后统一都按北京时间展示。这里我的经验是:系统内部存储时间统一用时间戳或 UTC,展示和统计统一转成用户所在时区,而不要让每一层各转一次,那样最容易出偏差。
5.4 权限“越权”问题的排查:边界条件比主流程更重要
权限模块上线前,我请测试同学重点做了权限边界测试。结果还真抓到了一个典型问题:列表页的权限过滤正常,但客户详情页的“导出”按钮可以直接把客户的完整资料导出为 Excel,而这个导出接口完全没有做数据权限过滤。也就是说,一个“仅本人”角色的用户,可以通过导出功能拿到系统里的所有客户数据。
这种问题非常隐蔽,因为它不在正常的“列表页浏览”主路径上,而是藏在“伴生功能”里。只要你新增一个接口,而这个接口又在查询客户数据,就必须同步考虑数据权限。后来我在代码评审里加了一条硬性要求:所有和业务数据相关的查询接口,都必须显式声明自己用哪种数据范围,不允许存在“无声明”的漏网之鱼。排查权限问题时,最快的办法是抓 SQL 日志,看看执行的 SQL 里有没有自动追加当前用户的数据权限条件。如果没有,那就立刻能锁定是拦截器没有生效,还是接口走的是自定义 SQL 绕过了拦截器。
写在最后的一点心得
项目做到后面我最大的体会是,DeskcommCRM 这种内部业务系统,难的不是技术,而是对业务的理解和对细节的坚持。一个字段的冗余、一个统计口径的确定、一个权限边界的处理,看起来都是小事,但累积起来就决定了这个系统到底是一个让团队觉得“好用”的工具,还是一个被天天吐槽的负担。如果让我重新做一遍,我一定不会在功能列表上堆更多东西,而是会花更多时间去一线听销售们怎么说话、怎么吐槽、怎么用 Excel 管理客户。系统不是设计师的自嗨,它是业务动作的数字化影子,影子贴不贴合实体,只有跑起来才知道。