通信即记录:桌面客户关系管理系统自研实战
DeskcommCRM这个项目是我前两年带着一个小型研发团队给公司销售和客服条线做的桌面端客户关系管理系统。项目名字是三个词的合体Desk桌面办公、Comm通信交互、CRM客户关系管理说白了一句话把坐席每天在桌面电脑前发生的电话、消息、客户资料、跟进记录全部收口到一个系统里。做它的直接原因并不高大上——当时团队管理客户主要靠Excel电话记录散在话单里客户跟进情况基本靠当事人脑子记销售一离职客户资源跟着断档。后来我意识到真正该做的不是换一套更重的CRM而是把“每一次通信都沉淀为业务数据”这条闭环打通。这篇文章不打算写成项目说明书只讲设计时的取舍、实现时的关键细节以及上线后踩过的坑给正在考虑类似系统的你做个参考。另外也说一下适合谁来读。如果你是在公司里负责销售运营、客服系统建设或者做后端、全栈开发想了解一套中小团队自研CRM应该怎么落地这篇文章应该能给你一些实际参考。里面不是通用产品文档里那种高大上的概念而是我们上线后真正沉淀下来、被团队验证过的东西。1. 整体设计思路先想清楚DeskcommCRM到底解决什么问题1.1 为什么不自研反而要做一套“小而重”的系统可能有人会问现在市面上的CRM产品那么多为什么还要自己造轮子我当时也犹豫过。最终没有采取采购方案不是因为商业产品功能不够而是我们最痛的那个场景它们管得不够细坐席一边通话一边快速记录、来电瞬间弹出客户历史、通话结束后自动生成跟进任务。这些东西在通用CRM里要么属于增值模块要么需要额外付费定制和通信网关的整合更是一笔大工程。倒不如自研一个轻量的核心系统先满足“通信即记录”这一条主线后面再接报表、接工单、接更多外部工具。这里面有一个很重要的取舍“重”在业务闭环“轻”在功能范围。DeskcommCRM系统只做客户资料、联系人、互动记录、跟进任务、看板这五件事不做复杂的销售漏斗、不做大而全的审批流先把坐席每天最常用的路径做顺。很多团队做自研系统失败不是因为代码写得差而是范围铺得太大最后连核心场景都没稳定。上线后再逐步补模块比一开始就把边界铺开更稳妥。1.2 系统架构与技术选型单体起步、通信优先架构上DeskcommCRM后端是Spring Boot 3单体应用数据库用MySQL 8.0缓存用Redis桌面客户端走Electron。为什么选这套组合一是团队对Java生态较熟招人容易二是系统初期规模不大几十个坐席同时在线单体足够支撑没必要一上来就搞微服务和K8s维护成本只会拖慢交付。桌面端选Electron是因为要实现软电话和消息提醒浏览器Web应用在音频设备调用、保持长连接上受限而Electron可以用同一套Web技术栈同时通过Node层访问本机资源。也可以做成纯B/S架构加浏览器软电话但实测下来Electron能更好处理来电弹屏的全局快捷键、断网缓存、开机自启动这类桌面场景。模块技术选型主要原因桌面客户端Electron Vue3音频设备控制、离线缓存、全局快捷键更可控后端服务Spring Boot 3 MyBatis-Plus团队熟悉度高、快速交付单体起步够用核心存储MySQL 8.0客户和互动记录的关系模型清晰事务要求高缓存与状态Redis坐席在线状态、接口幂等、临时令牌通信接入SIP话务网关 WebSocket统一管理来电外呼事件实时推送数据看板异步统计表 定时任务避免统计SQL拖垮在线业务1.3 数据模型设计把“客户”和“互动”两条线理清数据库设计是DeskcommCRM的重要一环我们用了大约三周反复调整核心是客户、联系人、互动记录、商机、跟进任务五张表。客户表存公司或组织级资料联系人是具体的人一个客户底下可以有多个联系人互动记录则把所有行为统一收进来包括来电、去电、短信、在线消息、手动备注统一用type字段区分。这样做的好处是查询某客户的“完整画像”只需一条SQL以联系人关联客户再按客户ID查互动记录排序后展示。最终做到“一次查询完整列表”。关键约束是每张表都要带owner_id和updated_at这为后面做数据权限和最近联系列表打下基础。客户表还带一个version字段做乐观锁避免两个坐席同时保存同一客户时互相覆盖。建表SQL如下这里只列客户表和联系人表的核心字段CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(120) NOT NULL, level TINYINT NOT NULL DEFAULT 1 COMMENT 1普通 2重点 3战略, owner_id BIGINT NOT NULL COMMENT 归属坐席ID, source VARCHAR(30) NOT NULL DEFAULT manual, remark VARCHAR(500) DEFAULT NULL, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_owner_updated (owner_id, updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户表; CREATE TABLE contact ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL, name VARCHAR(60) NOT NULL, phone_raw VARCHAR(30) NOT NULL COMMENT 原始号码, phone_e164 VARCHAR(30) NOT NULL COMMENT 标准号码, position VARCHAR(80) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_customer_id (customer_id), UNIQUE KEY uk_phone_e164 (phone_e164) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT联系人表;联系人表里phone_raw保存用户填写的原始号码phone_e164保存标准化后的号码。所有按号码查询的地方都用phone_e164否则客户用手机号带区号、固话带分机号的形式一多查询就乱套。互动记录表单独列出interaction_recordid、customer_id、contact_id、type、direction、duration、content、happen_at。索引设计为idx_customer_happen(customer_id, happen_at DESC)。提示数据表字段统一用UTC时间戳存储展示时由前端转本地时区。这样不同地域的坐席查看通话记录不会出现时间偏移。2. 核心模块实现要点来电弹屏、坐席状态与跟进的“硬骨头”2.1 来电弹屏从振铃到客户资料只要800毫秒来电弹屏是DeskcommCRM最受欢迎的功能。坐席电话响起屏幕上自动弹出客户资料、历史沟通记录、待办任务省去“你是谁之前聊过吗”的尴尬开场。实现流程SIP话务网关收到呼入后先把主叫号码交给CRM接口查询接口对号码做归一化按phone_e164精确命中未命中再尝试去掉区号、忽略分机号后进行模糊匹配返回命中的客户、联系人和最近五条互动记录前端窗口立即置顶显示并播放提示音如果未命中弹出一个“新客户快速建档”的小卡片坐席可以在一分钟内完成录入。整个过程要求接口响应在800毫秒以内实测慢的瓶颈往往不是数据库而是号码清洗逻辑里正则写得太重。后来我们把号码解析做成了预编译的Pattern并将普通号码查询走覆盖索引接口稳定在200毫秒左右。这里给出核心的号码归一化方法基于常见实践适合国内11位手机号场景其他国家需要按当地号段规则调整public static String toE164(String raw, String defaultCountryCode) { if (raw null) return null; String digits raw.replaceAll([^0-9], ); if (digits.startsWith(00)) digits digits.substring(2); if (digits.startsWith(0)) digits defaultCountryCode digits.substring(1); if (digits.length() 8) digits defaultCountryCode digits; return digits; }这个方法的核心思路是去掉所有非数字字符再补齐国际区号。实际生产中还要处理分机号比如“010-12345678转803”建议先用规则把“转”字后面的分机拆出来主号码和分机号分开存避免分机号污染主号码字段。2.2 坐席状态机与并发控制不能让同一个坐席同时接两通电话坐席状态是刚需在线、忙碌、小休、离线。一开始我们把状态放在后端内存里结果一部署多实例就出问题——两个实例各有一份状态坐席在A实例登录B实例查询不到软电话按钮状态和实际能力不一致。后来改用Redis保存状态key为agent:{id}:state字段包含status、extension、started_at、updated_at。状态切换需要满足状态机约束忙碌状态不能直接切到小休离线状态不能收新呼叫。状态切换接口统一走Redis Lua脚本保证并发下只有一个状态生效。一个坐席状态切换的Lua脚本大致如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(set, KEYS[1], ARGV[2]) end return 0这段脚本的作用是比较当前状态是否等于调用方传入的旧状态相等才写入新状态否则返回0。这样即使坐席快速连点两次按钮或者两个页面同时发出切换请求也不会出现状态被后到的请求反向覆盖的问题。状态切换看似简单实际并发场景很容易埋雷建议直接抄这个思路。除了状态还要维护坐席与SIP分机的绑定关系。绑定关系发生变化时要通过WebSocket通知网关和客户端否则会出现状态显示在线但分机未注册呼入直接漏接。这块属于“隐形但致命”的细节我们第一次上线时就吃过亏坐席明明在线电话就是打不进来。2.3 通话记录自动生成跟进任务规则驱动减少人工登记每次通话结束网关会推送一个CallEnd事件。收到事件后系统把它写入interaction_record并按规则判断是否生成跟进任务。规则不是写死在代码里而是放在数据库rule表管理员可配置。典型规则若通话时长大于30秒且客户等级为2或3则生成“次日联系”任务若呼叫未接通且客户等级大于等于2则生成“再次外呼”任务若客户在最近7天内连续来电3次以上则生成“异常诉求跟进”任务。实现上用一个轻量的规则引擎逐条判断条件命中则写入follow_up_task表并通过WebSocket推送到桌面端提醒。这里有个经验任务去重很重要否则一条被重复推送的通话事件会生成多条重复任务。方法是在interaction_record里保存一个uuid并在follow_up_task里对source_id和rule_code加唯一索引重复事件插入时静默跳过。用数据库唯一约束代替分布式锁更简单可靠也方便在问题发生后通过慢查询日志定位。跟进任务生成后坐席在桌面端会看到一个待办列表点开即可看到关联客户的资料和最近通话记录不需要跳转多个页面。这个“任务即入口”的设计极大减少了记录成本也让坐席更愿意按规定跟进。3. 实操复盘从零到一的关键路径与代码细节3.1 客户中心的查询链路与权限过滤客户中心页面是每一位坐席打开系统后的第一屏。它需要支持按归属人过滤、按关键词检索、按最近更新时间排序。对应的查询SQL是动态条件构造但有固定的过滤条件当前登录用户只能访问自己有权限的客户或同部门共享池客户关键词会同时匹配客户名、联系人姓名、联系人电话结果集默认限制50条通过游标分页而非深分页。动态SQL由MyBatis-Plus条件构造器实现同时要注意防止SQL注入排序字段使用白名单映射。页码从1开始翻到几千页时性能会下降后来改成了“前一页最后一条的updated_at id”作为游标体验明显更好。核心思路是任何查询都先带权限条件再带业务过滤最后排序顺序不能反。权限如果放在业务过滤后面就存在越权风险。有些系统为了图方便先查出业务数据再到内存里过滤用户权限数据量小的时候看不出问题坐席一多、客户一多接口响应直接翻几倍。权限过滤应该下沉到SQL层面利用联合索引一次查出可访问数据。3.2 点击拨号和通话状态回收事件流水是排查问题的救命稻草点击拨号的需求很简单在客户详情页点击电话号码桌面端软电话立即外呼。实现上前端页面向后端发起/api/v1/call/outbound带上contactId和agentId后端校验坐席状态为在线、分机已注册后返回taskId并异步通知桌面端执行拨号。桌面端收到指令后调用软电话SDK发起呼叫。因为涉及多个异步步骤我们把整个外呼过程拆成多个事件CALL_REQUESTED、CALL_ACCEPTED、CALL_ESTABLISHED、CALL_ENDED每个事件都写入call_event_log表WebSocket推送状态到前端。调试时能清楚看到哪一步断了。这个设计看起来多写了一点代码但后续排查问题非常省心强烈建议做通信类功能时保留事件流水不要只记最终状态。事件流水表设计如下CREATE TABLE call_event_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, call_id VARCHAR(64) NOT NULL COMMENT 通话ID, event_type VARCHAR(30) NOT NULL, event_data JSON DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_call_id (call_id), KEY idx_created (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT通话事件流水表;通话结束后事件里会附带话单ID、开始时间、结束时间、时长、挂断方向。这一步拿到数据后再更新互动记录和跟进任务形成闭环。用户反馈“电话打完了系统没记录”十有八九是某个事件丢了这时候直接查call_event_log比对事件链路很快就能定位是网关没推、接口没收到还是收到后写库失败。3.3 外部系统对接签名、防重放与数据权限DeskcommCRM少不了与工单系统、企业微信等外部系统对接。所有对外接口统一要求AppId标识调用方时间戳timestamp业务参数body签名sign HMAC-SHA256(secret, timestamp \n method \n path \n body)。服务端校验签名之前先校验时间戳超过五分钟的请求直接拒绝防止重放攻击。这个方案实现简单对外部系统开发者也友好。签名校验逻辑示例String payload timestamp \n method \n path \n body; String calculated HmacUtils.hmacSha256Hex(secret, payload); if (!calculated.equals(sign)) { throw new ApiAuthException(sign mismatch); }权限控制方面除了登录用户角色数据权限还要细化到数据范围本人、本组、本部门、全部。这样不同岗位看到的客户池不同。我们使用的是RBAC模型在资源上配置权限点在角色上绑定权限点用户关联角色。数据范围不是挂在角色下而是单独字段维护因为同一个角色在不同部门的数据范围可能不同。编辑客户时还需要检查当前用户是否是该客户的负责人否则拒绝操作。这个逻辑要在Controller层拦截不能只靠前端隐藏按钮。3.4 看板与统计用异步统计表扛住业务高峰坐席主管每天都看接通率、平均通话时长、待跟进任务数、活跃客户数。一开始直接执行实时统计SQL结果在通话高峰时段统计查询把主库拖慢导致弹屏变卡。后来做了两个调整一是查询走只读从库读压力从主库剥离开二是对核心指标做异步统计每15分钟由定时任务汇总到dashboard_daily表页面上展示的是统计结果表而非实时聚合。日维度看板延迟15分钟完全可接受运营和主管也认可。下面是两个常用指标SQL从库执行-- 当日接通率 SELECT COUNT(*) AS total_calls, SUM(CASE WHEN call_result ANSWERED THEN 1 ELSE 0 END) AS answered_calls, ROUND(SUM(CASE WHEN call_result ANSWERED THEN 1 ELSE 0 END) / COUNT(*) * 100, 2) AS answer_rate FROM interaction_record WHERE type CALL AND happen_at CURDATE();统计口径上最容易踩坑的是“接通”怎么定义。我们规定振铃超过6秒且对方摘机才记为接通少于6秒视为误触或未接。建议统计口径一定写进文档且所有报表统一用同一个口径否则技术侧和业务侧会对不上数。有一次管理层看到接通率突然下滑排查了整整一天最后发现是运营同学用“响铃就算接通”的口径自己在Excel里拉数两边数字自然对不上。4. 桌面端体验与离线策略容易被忽略但决定成败的细节4.1 全局快捷键、消息提醒和免打扰桌面端是坐席每天连续使用八小时以上的工具体验打磨不好再强的业务功能也会被吐槽。我们做了几个看起来很琐碎但实际价值很高的功能全局快捷键即便坐席在浏览器里查资料按AltSpace也能立刻唤出客户搜索框新客户来电时桌面右下角弹出通知并自动播放提示音。Electron的globalShortcut模块可以注册全局快捷键但要注意注册失败的情况比如其他软件已经占用了按键组合。我们把这些快捷键做成了可配置项默认AltSpace坐席可以自己改成顺手的方式。系统默认开启免打扰模式午餐和下班后不弹声音提醒只做任务栏徽标闪烁避免高频通知打断坐席。桌面通知的细节也值得说一句Windows系统默认通知如果设置成urgent级别会把焦点抢走坐席正在打字时突然弹窗输入焦点丢失是特别恼火的事。我们统一把桌面通知设置为普通级别配合右下角气泡展示既不打断操作也不遗漏提醒。这个细节是团队内测时被坐席反馈最多的一项改完之后好评度提升明显。4.2 离线缓存与本地队列断网也不能断业务呼叫中心对网络稳定性要求很高但办公网络偶尔也会波动。为了不让坐席断网后彻底停摆我们在桌面端引入了本地缓存和本地事件队列。坐席最近查看过的100条客户卡片、最近一周的互动记录会缓存到本地SQLite数据库如果网络断掉坐席仍然可以查看历史记录、记录备注备注先写本地队列等网络恢复后再带上pending标记同步到服务端。服务端收到带pending标记的备注时不直接覆盖原记录而是作为追加内容写入互动记录并标记为“离线补录”。这样既保证数据不丢也避免离线期间的本地修改把其他坐席的更新覆盖掉。离线缓存有一个安全注意点本地数据库不能直接存明文手机号、身份证号等敏感信息。我们的做法是使用SQLCipher做SQLite层加密即使笔记本丢失磁盘数据也不容易被直接读取。缓存过期时间设置为72小时超过后必须重新联网验证登录态并刷新缓存。这部分的优先级虽然不像在线业务那样高但真正遇到断网时能救急建议做桌面端系统时不要省略。5. 常见问题与排查技巧这些坑我替你踩过了上线五个月我们处理过不少问题。下面列出的几个是反复出现、最值得注意的多数问题是逻辑设计层面的不是代码笔误。整理成速查表方便你对照。问题现象根因解决措施来电弹屏偶尔不出来号码归一化逻辑不统一座机和手机号格式差异统一E.164规范查询前强制归一化并给phone_e164加索引点击拨号没有反应坐席状态在Redis中显示在线但SIP分机已掉线分机注册状态与坐席状态绑定掉线时自动通知前端置灰坐席状态被“抢”了两个请求并发修改同一状态内存状态互相覆盖改用Redis Lua脚本做原子状态切换重复生成跟进任务网关重试推送同一通话事件follow_up_task对source_id和rule_code加唯一索引客户详情页打开很慢深分页导致大量回表或权限子查询拖慢改游标分页并把权限过滤字段放联合索引看板统计拖慢业务统计SQL在主库执行高峰期争抢资源查询走从库核心指标异步统计来电弹屏不出来这个问题我们排查时发现明明话单里有号码但查询无命中。后来发现自助导入的号码带空格和横线接口却只做了去横线没有兼容“86”前缀。后来统一用toE164方法处理问题才消失。如果你也遇到类似现象建议先查一下数据库里contact表的phone_e164字段看看存的是不是统一格式这一步能筛掉一半问题。重复跟进任务是另一个重灾区。SIP网关为了保证事件不丢失设计了重传机制但我们的回调处理没有做幂等结果同一通电话生成了两个回访任务。后来在follow_up_task表里加了唯一索引问题就彻底解决了。另外一个容易忽略的场景是版本号问题两位坐席同时编辑同一个客户后保存的人覆盖先保存的人备注丢失。后来在UPDATE语句中带上WHERE version 旧version影响行数为0时提示“资料已被他人修改请刷新”体验改善明显。6. 上线后的复盘与可复用的经验DeskcommCRM上线四个月后整体效果超出我的预期。客户资料完整率从不到60%提升到95%以上通话完成后48小时内有跟进记录的客户占比从以前的靠自觉变成了一周稳定在82%左右销售离职时的客户交接也从“人走了资料找不到”变成后台一键导出交接清单。让我觉得值得的还不是这些数字而是团队从“应付记录”慢慢变成了“按流程做事”因为系统帮他们省下了回忆和录入的时间。如果让我总结哪些经验最值得复制我会说三条。第一先记录、后智能。我们一开始没有做客户意向预测、智能标签等技术尝试而是先把通信、记录、任务这条主链路做扎实数据积累到一定量之后再做分析才真正有意义。第二通信数据要从第一天就当作业务数据建模。通话记录不只是一条话单它关联客户、联系人、商机、任务字段和关系在初期就要设计好后面返工成本极高。第三给团队留出培训时间。功能做得再顺手也有人在初期会抵触我们的做法是安排了两轮“模拟坐席日”让全组在测试环境演练一遍完整流程上线后抵触声音小了很多。最后分享一个建议把“客户能不能在3秒内看到历史互动记录”作为这套系统成功的底线。这个体验做好了用户接受度就上去了一半。其他的模块可以等核心流程跑顺之后再慢慢扩展。