从零落地一套 DeskcommCRM:从需求拆解到调度实现一听名字有点带感——DeskcommCRM,桌面通信和客户关系管理的组合。我最初的理解是“带即时通信能力的CRM”,做出来之后才发现,真正让团队买单的不是聊天窗口,而是把“客户、跟进记录、协作、消息提醒”揉进一个桌面前端的工作流里。这篇博文我不会讲那种纯概念化的“什么是CRM”,直接说清楚怎么落地、怎么设计、怎么绕开那些坑。
如果你正准备自研一套内部CRM,或者想把现有客户管理从Excel搬到桌面系统上来,这篇文章应该能帮你省掉不少试错时间。我会从需求拆解、技术选型、数据库设计、核心功能实现、消息模块、部署交付和后期维护这几个维度,完整复现一遍 DeskcommCRM 的构建过程。
1. 整体设计与思路拆解
1.1 先搞清楚“桌面 + 通信”到底意味着什么
DeskcommCRM 这个名字拆开看,“Desk”说明它不是一个纯网页端的轻应用,而是有桌面客户端的形态;“comm”代表通信能力,意味着客户跟进、消息提醒、内部协作不能只是“记录”,要能主动推送;“CRM”则是核心业务域——客户、商机、合同、售后这一条链。
刚开始我差点掉进一个误区:费力气把 Web 端套个 Electron 壳,再做一套聊天界面,以为是“桌面通信 + CRM”。但实际给业务人员试用后,反馈很一致:他们不关心你是不是桌面端,他们关心的是——客户来消息时能不能第一时间弹出来?谁的客户,跟到哪一步了,接下来该干嘛?今天的任务清单能不能一打开就看到?
所以整体设计定了三个原则:
- 桌面客户端负责“高频操作 + 消息触达”,Web 端做后台管理和报表查看。
- 通信能力优先对接现有渠道(邮件、企业微信、短信网关),做一个统一收件箱,而不是再造一套 IM。
- 所有客户行为都要沉淀成“跟进记录”,让销售过程可以被回看、被统计。
这套设计思路决定了后续所有表结构和接口的形态。它不是一个“有聊天功能的CRM”,而是一个“客户信息驱动消息动作、消息动作又反哺客户信息”的系统。
1.2 技术栈选型:为什么是这套组合
技术上,我最终选型的组合比较主流,也方便后续接手:
- 客户端:Electron + Vue 3 + TypeScript,内置 SQLite 做本地缓存和离线状态。
- 后端:Spring Boot 3.x + MyBatis-Plus,认证用 Sa-Token,任务调度用 Quartz。
- 数据库:MySQL 8.x,关键词检索用全文索引,核心表按业务域分库分表预留。
- 消息模块:WebSocket 做实时消息推送,邮件通过 IMAP 拉取富文本解析,短信走阿里云网关 API。
- 部署:Docker Compose 编排,Nginx 转发 WebSocket 和 API 请求。
为什么不用微服务?理由很简单:团队规模在 10 人以内,业务复杂度还没到必须拆服的程度。一个单体应用,模块边界清晰,比一上来就上全套微服务体系更稳。等客户量真正上来以后,按客户域、消息域、报表域拆开也不晚。
客户端选 Electron 而不是做纯 Web,主要图两点:一是系统托盘常驻,有消息可以直接弹通知;二是本地能缓存最近六个月的数据,网络不好的时候,销售也能打开客户资料和跟进记录继续工作。这是纯 Web 页面做不到的。
1.3 DeskcommCRM 的能力边界规划
为了避免项目失控,第一版我明确圈定了能力边界。不是所有功能都要做,先把主线跑通:
- 客户管理:客户档案、联系人、公司信息、来源渠道。
- 商机管理:销售漏斗,不同阶段字段配置,赢单/输单原因。
- 跟进记录:电话、邮件、面谈、IM聊天记录,可以按时间线查看。
- 任务与日程:给客户/商机打标签,创建跟进任务,设定提醒时间。
- 消息中心:邮件、短信、IM 消息的聚合收件箱,WebSocket 实时推送。
- 报表看板:按人、按团队、按来源统计转化率和回访率。
- 权限系统:管理员、销售主管、普通销售、只读访客四类角色。
每个模块包含的标准比较清楚,后续迭代时每加一个功能都可以先问:它是让客户资料更完备,还是让跟进效率更高?如果都不沾,就先不做。
2. 核心细节解析与实操要点
2.1 数据库设计:客户表到底该怎么建
客户表是整个 CRM 的地基,建不好后续全是坑。我见过很多表,把客户名、联系人、手机号、公司地址全塞在一张表里,结果一个客户有多个联系人的时候只能搞多条重复记录,统计数量永远对不上。
我在 DeskcommCRM 里拆了客户主表、联系人表、公司信息表,用逻辑外键关联:
customer(客户主表) - id, customer_name, level, source, owner_id, status, remark, created_at, updated_at contact(联系人表) - id, customer_id, name, title, phone, email, wechat, is_primary company(公司信息表) - id, customer_id, company_name, industry, scale, address, website按照第三范式的思路,客户主表里不冗余公司地址和联系人姓名,查询的时候通过 JOIN 操作或视图取全量信息。
比较关键的一个字段是owner_id,这代表客户的归属人。很多小团队刚开始不会在意这个字段,等到要算提成、要分客户的时候才发现权限全靠它。还有一个隐蔽的坑是“客户去重”,同一个公司可能有多个联系人分别联系过,我设计了一张customer_merge_log表,记录被合并的客户 ID,保留合并主 ID,历史跟进记录全部迁移到主 ID 下,避免数据链断裂。
2.2 跟进记录的不可变设计
跟进记录是销售的证据链,也是管理层复盘的核心数据。我参考的是不少财务系统的设计思路:写进去就不能改,只能补充更正。
这样设计的逻辑其实很直白:
- 销售战报不能任意修改,否则月底统计谁能保证数据真实性?
- 老板想看一条完整的跟进时间线,如果业务员偷偷改了某天的记录,线索就断了。
- 合规角度,客户沟通记录本身就是企业资产,应该保留归档。
所以follow_up_record表设置为主表 + 追加表模式:
CREATE TABLE follow_up_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL, contact_id BIGINT, biz_type TINYINT COMMENT '1-电话 2-邮件 3-面谈 4-IM 5-其他', content TEXT NOT NULL, creator_id BIGINT NOT NULL, create_time DATETIME NOT NULL, INDEX idx_customer_time (customer_id, create_time) ) COMMENT '跟进记录主表'; CREATE TABLE follow_up_extra ( id BIGINT AUTO_INCREMENT PRIMARY KEY, record_id BIGINT NOT NULL, field_key VARCHAR(64) NOT NULL, field_value TEXT ) COMMENT '跟进记录扩展字段表';这样主表保持精简,任何额外字段(比如录音文件地址、邮件原文内容、合同扫描件 URL),都通过follow_up_extra灵活追加,不用频繁改表结构。
2.3 提醒和时间线机制是怎么调度的
一开始我用的是“定时轮询数据库”的简单方案:每 5 分钟扫一次任务表,把到期任务推送出来。客户一多,200 个业务员同时操作,发现两件事——数据库压力变大,消息延迟明显。
后来我改造了一下:把未来 24 小时内要提醒的任务加载到 Redis 的 ZSET 里,以时间戳作为 score,Quartz 每 30 秒扫描一次 ZSET 集合,只拉取当前时间前后一分钟的任务,推送给相关用户,并推送 WebSocket 事件。数据库轮询从“每5分钟全表扫”变成“每小时重新加载一次 ZSET”,量级下降两个数量级。
调度流程图大概是:
- 任务创建 -> 计算下次提醒时间 -> 写入 MySQL -> 同步写入 Redis ZSET。
- Quartz 任务每 30 秒执行 -> ZRANGEBYSCORE 拉取到期任务 -> 生成消息通知 -> 推送 WebSocket -> 状态置为已提醒。
- 未提醒成功的任务会保留到 Redis 待处理队列,重试三次,三次都失败则标记异常,人工介入。
这个机制的好处是,即使业务系统重启,ZSET 数据丢了,从 MySQL 重新加载下一天的提醒任务也能兜底,属于双保险。
3. 实操过程与核心环节实现
3.1 消息模块的工程化实现
DeskcommCRM 的通信能力核心是消息模块。它做了三层抽象:
第一层是消息渠道适配层,把邮件(IMAP/SMTP)、企业微信、短信网关统一成标准的MessageEnvelope结构体。
public class MessageEnvelope { private String channel; // email / wecom / sms private String direction; // inbound / outbound private String from; private String to; private String subject; private String content; private String rawPayload; private Map<String, Object> customHeaders; }邮件属于典型的消息源,所以需要先开发 IMAP 拉取服务。这里有几个关键点需要注意:
- IMAP IDLE 并非所有邮件服务商都支持(QQ 邮箱支持,部分自建邮件系统不支持)。如果服务商不支持 IDLE,就用定时拉取。
- 邮件正文可能是纯文本、HTML、附带 Base64 编码的附件,解析时必须做编码检测,否则中文乱码。
- 同一个会话的邮件多线程回复时,
Message-ID和References头字段必须保存,才能把往来邮件串成时间线。
短信接入相对简单,主要是通过网关 API 发送。发送前做敏感词过滤,发送后轮询状态报告,把最终送达状态回写数据库。
所有渠道的入站消息最终都会统一写入message_center表:
CREATE TABLE message_center ( id BIGINT AUTO_INCREMENT PRIMARY KEY, channel VARCHAR(16) NOT NULL, direction VARCHAR(8) NOT NULL, from_address VARCHAR(128), to_address VARCHAR(128), subject VARCHAR(512), content LONGTEXT, customer_id BIGINT, contact_id BIGINT, related_record_id BIGINT, status TINYINT DEFAULT 0, create_time DATETIME, INDEX idx_customer_status (customer_id, status) ) COMMENT '聚合消息中心';customer_id和contact_id这两个字段,通过消息地址自动匹配客户。匹配不上的消息,会进入“待认领”池子,由销售手动绑定客户,这也算一个实际非常好用的功能。
3.2 WebSocket 推送的会话管理
消息推送如果用“前端轮询接口”的方式,既浪费资源,延迟也高。我选用了 WebSocket 做实时通道,但要解决三件事:
- 认证握手。
- 客户端断线重连。
- 多端消息同步。
认证握手其实不复杂,客户端拿着已登录的 Token,在 WebSocket URL 上做参数传递。后端在HandshakeInterceptor里校验 Token,存入会话属性。
public class AuthHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) { String token = ((ServletServerHttpRequest) request).getServletRequest().getParameter("token"); Long userId = JwtUtil.parseUserId(token); if (userId == null) { return false; } attributes.put("userId", userId); return true; } }断线重连的机制就是前端监听 WebSocketclose事件,指数退避重连。重连成功后,前端会向服务端发一个sync事件,服务端把断线期间的消息拉取回来,保证不漏消息。
多端同步的意思是,业务员可能同时登录着桌面端和 Web 后台,两者要共享已读状态。WebSocket 推送可以使用 RabbitMQ 的广播模式,后端集群的每个实例都能收到消息,再推给连接到当前实例的客户端。这个小集群设计虽然在一开始没有立刻用上,但结构上是预留的,后续扩容不用改代码。
3.3 桌面客户端的本地缓存逻辑
Electron 客户端在我的设计里是一个“带离线能力的业务终端”。首次登录后,后端会把当前登录人负责的客户列表、任务列表、基础配置下发,客户端用 SQLite 缓存。
查询客户时优先走本地 SQLite,本地没有数据或数据超过 TTL 才回源请求接口。这个设计在弱网环境实测效果很明显,页面打开速度快非常多,输入关键词检索的响应时间几乎无感知。
但这里有个需要特别小心的地方:权限模型。离线缓存不能把不属于当前登录人的客户数据全部缓存下来,否则任何懂一点 SQLite 的人都能打开本地库看所有客户资料了。所以后端下发数据时就严格控制了范围,只下发:
- 当前用户是负责人的客户。
- 当前用户参与协作的客户。
- 当前用户可见的共享公海客户(按规则过滤)。
本地缓存表都做了加密,SQLite 数据库文件用 Electron 的安全存储存放。当然,彻底防止用户导数据是做不到的,但至少能做到基本合规。
3.4 权限系统的落地方式
权限是 CRM 系统里让很多人头疼的地方,没有谁希望销售去看其他销售的合同金额,也不希望普通员工直接导出全量客户。
我的权限设计是“角色 + 数据范围”的双层模型:
- 功能权限:菜单按钮,管理员配置角色后,登录时一次性返回。
- 数据权限:普通销售只能看到自己名下的客户;销售主管能看到本团队所有客户;管理员能看到全部。
数据权限的通用做法是在 SQL 层拼条件。我把这种条件封装成了一个注解:
@DataScope(table = "c", alias = "c", field = "owner_id") public class CustomerQueryDTO { private String keyword; private Long ownerId; private String status; }MyBatis 拦截器会拦截带有@DataScope注解的 Mapper 方法,根据当前登录人角色自动追加WHERE条件。比如管理员查询不加限制,销售主管追加owner_id IN (子团队成员),普通销售追加owner_id = 当前用户。
这种方案的好处是业务代码里没有散落任何权限判断,但代价是 SQL 必须按规范写别名,否则拦截器无法识别条件。需要在团队规范里强调这些硬性要求。
3.5 Docker Compose 部署实战
部署环节我用 Docker Compose,把 MySQL、Redis、后端服务、Nginx 编排在一起。整个过程下来,最大收益是换一台服务器也可以在 5 分钟内拉起整套环境。
version: "3.8" services: mysql: image: mysql:8.0 container_name: deskcomm-mysql restart: always environment: MYSQL_ROOT_PASSWORD: your_secure_password MYSQL_DATABASE: deskcomm ports: - "3306:3306" volumes: - ./data/mysql:/var/lib/mysql - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql redis: image: redis:7-alpine container_name: deskcomm-redis restart: always ports: - "6379:6379" volumes: - ./data/redis:/data backend: build: ./backend container_name: deskcomm-backend restart: always depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql REDIS_HOST: redis ports: - "8080:8080" volumes: - ./logs:/app/logs nginx: image: nginx:1.24-alpine container_name: deskcomm-nginx restart: always depends_on: - backend ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./web/dist:/usr/share/nginx/html需要注意的几个部署细节:
- Nginx 配置 WebSocket 转发的超时时间。不设置的话,WebSocket 连接大约 60 秒就会被断开。
- 后端 API 和 WebSocket 用同一个域名,通过
location路径区分,这样可以少处理一套跨域问题。 - MySQL 的
sql_mode如果包含ONLY_FULL_GROUP_BY,统计类 SQL 容易报错,需要在初始化 SQL 里设置合适的模式。
4. 常见问题与排查技巧实录
4.1 消息模块推送延迟,问题出在哪里
做联调时遇到一个很典型的坑:WebSocket 连接正常,但消息推送延迟少则几十秒,多则几分钟。排查过程也给大家参考:
首先看 WebSocket 是否存在心跳机制。浏览器或 Electron 端的 WebSocket 连接如果长期不发数据,Nginx 默认的proxy_read_timeout 60s会先断开,客户端重连成功之后,消息自然会积压一段时间。
解决方案是在 Nginx 配置文件里把这些参数加到 location:
location /ws { proxy_pass http://backend:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }其次是消息服务里做耗时操作的问题。最开始我在接收邮件时直接解析 HTML、生成摘要、匹配客户,这些步骤都要跑完才推送 WebSocket 通知,导致用户看到通知的时候已经是很久以后的事情了。
后来我改成异步任务:收到邮件立刻返回“已接收”,投递到消息处理队列(用 RabbitMQ 或线程池异步处理),解析、匹配客户、生成时间线这些工作全部放到后台完成,解析完成后再推 WebSocket 通知给相关负责人。这样体验就顺滑多了。
4.2 客户去重时的数据流失问题
有一次测试导入客户数据,发现重复客户被合并后,历史跟进记录全部丢失了。排查发现是合并操作只改动了客户主表的 ID,没有同步更新follow_up_record.customer_id。
这个问题的根因是我在合并客户时只做了主记录删除和备用记录保留,但遗漏了关联数据迁移。修复后的正确姿势是:
- 开启事务。
- 更新所有关联表(
follow_up_record、message_center、task)的customer_id。 - 将原客户的状态置为已合并。
- 将原客户 ID 写入
customer_merge_log,联动记录入库。 - 提交事务。
4.3 桌面端本地数据库文件越来越大
Electron 客户端里的 SQLite 缓存,运行几个月之后会积累大量历史消息和已归档客户资料,数据库文件动辄上 GB,磁盘和启动速度都受影响。
我尝试过给本地配置表加了 TTL 字段,查询时自动过滤失效数据;后来发现更简单的做法是“本地清理 + 增量重建”策略:
- 每 7 天自动清理最近 180 天之前过期的消息内容。
- 清理前发送“清理确认”到消息中心,避免销售以为数据丢了呢。
- 清理动作本身做成一个可追踪的本地事件,随时可以回看。
这样本地方案长期跑下来基本不会膨胀到不可控的程度。
4.4 报表统计数字对不上
月初算销售转化率时,发现日报、周报、月报数字不一致。原因很简单:不同报表的 SQL 使用的统计口径不同。
比如“新增客户数量”这个指标,有的地方用的是created_at(创建时间),有的地方用的是first_follow_time(首次跟进时间),两者在跨月场景下天然不相等。
修复办法是:在系统里定义统一指标字典,每个指标对应唯一 SQL 片段,报表查询全部从指标字典里引用,禁止业务同学直接写 SQL。这个口径标准化做扎实以后,数字对不上的问题基本绝迹。
5. 影响范围与实践沉淀
5.1 DeskcommCRM 上线后对团队工作方式的改变
上线大概六周后,我明显感觉团队的工作方式发生了变化。几个比较典型的场景:
- 以前销售每天早上的第一件事是翻群消息、翻邮件,现在直接看桌面端“今日待办”就有结果。
- 客户资料不再散落在个人微信、Excel 表格里,统一的客户时间线下,谁跟进到哪一步一目了然。
- 商机阶段的更新会自动通知销售主管,主管不用再追着下属问“这单怎么样了”。
- 消息中心聚合了邮件和IM沟通,客户问“上次报价是多少”,直接检索消息记录就能找到。
最有意思的是一个销售说,他以前最怕客户电话里问“上次你说的那个方案是啥来着”,现在他敢直接说“您稍等,我看一下系统”,然后从时间线里翻出对应的沟通记录和报价单。这个改变虽小,但带来的信任感和专业度提升是实实在在的。
5.2 从一次 CRM 开发中得到的项目管理经验
分享几个这趟开发过程中沉淀的经验:
- 先做数据模型,再做界面。真正的坑几乎都出在数据关系没理清,后面被迫改表。
- 权限别想着后期补,一开始就要做主数据隔离,不然后期改 SQL 是个大工程。
- 消息推送尽量异步化,不要让用户感知到“等待”。
- 报表口径要统一,指标字典在第一次迭代就要建立,别等数字对不上了再做标准化。
- 桌面端不能只做壳,离线可用才是核心价值,否则用户宁可使用浏览器。
5.3 后续可以继续扩展的方向
第一版跑通之后,思路也跟着打开了。后续我计划在 DeskcommCRM 上做三件事:
第一,移动端考虑用 Flutter 做一个轻客户端,虽然桌面端适合办公室使用,但外出拜访客户时,移动端仍然是一个高频刚需。
第二,机器学习的语义分析可以引入到消息模块里。识别邮件中客户提到的“预算”“时间”“决策人”等关键词,自动生成商机阶段变更建议。这个在技术实现上不难,关键在于模型训练需要积累足够的语料。
第三,把开放 API 做完整。让客户已有的财务系统、订单系统能通过标准接口与 CRM 双向同步,避免客户数据在多个系统之间来回倒腾。做好了,它就是团队的核心业务底座,而不仅仅是一个数据库外壳。
我的体会是,CRM 这种系统的价值不在系统本身,而在于它能不能让使用它的人“少记一点事、少重复说一遍话”。真正把数据关系理顺了,把消息闭环做到位,它就会从“被逼用的工具”变成“大家主动打开的工作台”。