通信即记录:桌面客户关系管理系统自研实战
2026/9/19 20:32:03 网站建设 项目流程

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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表';

联系人表里phone_raw保存用户填写的原始号码,phone_e164保存标准化后的号码。所有按号码查询的地方都用phone_e164,否则客户用手机号带区号、固话带分机号的形式一多,查询就乱套。互动记录表单独列出interaction_record:id、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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 全局快捷键、消息提醒和免打扰

桌面端是坐席每天连续使用八小时以上的工具,体验打磨不好,再强的业务功能也会被吐槽。我们做了几个看起来很琐碎但实际价值很高的功能:全局快捷键,即便坐席在浏览器里查资料,按Alt+Space也能立刻唤出客户搜索框;新客户来电时,桌面右下角弹出通知并自动播放提示音。Electron的globalShortcut模块可以注册全局快捷键,但要注意注册失败的情况,比如其他软件已经占用了按键组合。我们把这些快捷键做成了可配置项,默认Alt+Space,坐席可以自己改成顺手的方式。系统默认开启免打扰模式,午餐和下班后不弹声音提醒,只做任务栏徽标闪烁,避免高频通知打断坐席。

桌面通知的细节也值得说一句: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秒内看到历史互动记录”作为这套系统成功的底线。这个体验做好了,用户接受度就上去了一半。其他的模块,可以等核心流程跑顺之后再慢慢扩展。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询