2026/9/16 7:49:27

自研CRM系统实战:从需求拆解到技术落地与避坑指南

自研CRM系统实战:从需求拆解到技术落地与避坑指南 从零落地一套 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 里以时间戳作为 scoreQuartz 每 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 MapString, 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, MapString, 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 配置文件里把这些参数加到 locationlocation /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 这种系统的价值不在系统本身而在于它能不能让使用它的人“少记一点事、少重复说一遍话”。真正把数据关系理顺了把消息闭环做到位它就会从“被逼用的工具”变成“大家主动打开的工作台”。