自研DeskcommCRM:从通讯数据到客户管理的工程实践
1. 为什么我们最后决定自研DeskcommCRM客户信息散落带来的连锁反应那段时间公司的状态大概是这样的销售在用企业微信和客户聊用个人邮箱收报价单座机通话记录散落在话务台系统里Excel表格里躺着三份不同版本的客户名单。最要命的是一个跟了大半年的核心客户对接人出差回来换了个手机号记录在通话记录里但是谁也找不到。我们当时就在想一件事如果CRM系统不能把日常通讯动作全部吸收进来那它本质上只是一个让人填表的库存软件。DeskcommCRM这个名字拆开看就是桌面通讯客户关系管理。立项时我们内部说的不是做个CRM而是把客户沟通的每个动作变成可检索、可追溯、可交接的数据资产。目标用户就是公司内部十几号销售和客服他们不需要复杂的市场漏斗、不需要营销自动化最痛的点就三个客户资料统一、沟通记录完整、离职交接不丢人。我也认真对比过市面上的现成产品。主流SaaS CRM功能确实丰富但到了批量导入通话记录、对接运营商话单、按公司自定义组织架构控制数据权限这些环节要么需要加钱开企业版接口要么只能在售后流程里绕弯子。而纯开源的CRM系统客户字段模型和权限体系改起来并不轻松。算了一笔账三年代理费用加定制开发费用够我们养一个半后端开发了。而且我要的不只是业务数据进系统还要让通讯数据实时进系统这个诉求在当时的选择列表里几乎找不到贴合的方案。于是DeskcommCRM以一个不太正规的方式启动了一个后端、一个前端、加上我这个兼着产品经理的技术负责人用了大概四个月做出了第一版可用的系统。这篇文章不聊商业愿景只聊技术方案和落地过程中那些文档里不会写的东西给有类似场景的团队做个参考。2. 地基设计联系人主数据模型与事件总线先想清楚这三件事这类系统最容易犯的错误是一上来就画一堆用户表、客户表、跟进记录表结果数据结构膨胀到后面根本没法维护。我们只花了不到两周定数据模型但把三个问题想透了。2.1 客户模型的公司-联系人两级结构与合并规则真实业务里客户不是一个扁平的姓名电话。一个客户可能是某科技有限公司下面有三个对接人一个管采购、一个管技术、一个管付款。所以DeskcommCRM的客户主数据采用两级模型customer表存公司维度contact表存联系人维度联系人通过customer_id归属到公司。每个联系人除了基础字段还冗余了last_contacted_at和first_contacted_at。冗余这两个字段纯粹是为了列表页排序快省去每次都要JOIN通讯记录表聚合计算的开销。别小看这个设计当通讯记录涨到几十万条后这种冗余字段能帮你少写很多麻烦的查询。合并规则是我坚持要做的。销售导入数据时经常出现同一个公司被录了三次三个公司名分别是全称、简称、英文名的情况。我们在系统里维护了一个归一化规则库流程是这样的录入新客户时先用统一社会信用代码精确匹配查不到就做公司名的标准化处理去掉有限公司股份有限公司等后缀转小写。标准化后仍不能精确匹配就触发人工审核合并队列由管理员确认后合并。合并时联系人、通讯记录、跟进记录全部挂到保留的那条客户记录下源记录标记为merged_into。这套规则谈不上智能但非常务实。它避免了依赖第三方地址库或NLP服务的额外复杂度也基本覆盖了90%的重复录入场景。2.2 把通讯记录当事实原稿把业务字段当推导结论DeskcommCRM最核心的设计思想是通讯记录是事实原稿客户画像里的标签、阶段、意向等级都是推导结论。为什么这么分因为业务字段一定是会改的销售今天给客户标高意向明天可能改成暂缓跟进。但如果基础通话记录错了那后面所有推导都是错的。我们把通讯记录设计成不可变数据源任何写入操作都只能追加不允许更新和删除数据库账号也只授予了INSERT和SELECT权限UPDATE和DELETE只能通过运维后台用专门的审批流程操作。客户画像字段则是另外一种机制。每次通讯事件发生后系统会跑一组规则计算器重新推导客户的活跃度、最近跟进间隔、沟通渠道偏好然后把结果写进画像表。这样做的好处是你回溯任何一个时间点的客户状态都能用当时的事件快照重新推演而不是依赖一张被无数次覆盖的表。2.3 事件总线选型的务实考量通讯触点的类型很多电话、短信、网页留言、企业微信消息偶尔还有邮件。我们希望所有触点的事件都走一条统一通道然后由不同的消费者去处理——一个消费者负责写入通讯归档一个消费者负责触发画像重算一个消费者负责给相关销售推送站内通知。选型时我们没有直接上Kafka。理由很简单团队没人专职运维Kafka而且我们的消息量一天最多也就几万条。最后选的是RabbitMQ。理由是部署简单一个节点就能跑社区资料多排查问题容易。原生支持延迟队列插件做客户超48小时未回复提醒这类业务非常顺手。基于AMQP协议客户端库在Java、Python、Node.js里都很成熟。事件统一格式长这样{ event_id: evt_8f3a1c2e9d, event_type: call.ended, occurred_at: 2024-11-20T14:23:0508:00, channel: sip, direction: outbound, customer_ref: { customer_id: 1024, contact_id: 88 }, raw_payload: { call_id: ca_778812, from: 1001, to: 13800138000, duration_sec: 126, record_url: https://oss.example.com/record/778812.mp3, disposition: answered } }所有表结构的设计都围绕这个事件原稿展开customer、contact、user这些业务表反而是从事件里反向投影出来的。这个决定在后期被证明是整系统最值得的一笔投入。3. 三类触点接入的实操记录软电话、邮箱、IM各踩各的坑触点接入是这个项目里工作量最大的部分。我们接了三类渠道SIP软电话、企业邮箱、企业微信/网页客服。每一类都有自己特有的坑。3.1 软电话接入用SIP话机和CTI链路把通话变成结构化事件办公电话这块我们没有做完整的WebRTC软电话UI而是接了一款开源的软电话终端重点做的是CTI计算机电话集成链路。简单说就是软电话终端通过SIP协议注册到我们自建的FreeSWITCH实例同时终端和DeskcommCRM之间维持一条WebSocket长连接用来实时上报呼叫状态。整个呼叫流程是这样的销售在CRM客户详情页点击呼叫按钮后端调用FreeSWITCH的API发起外呼。FreeSWITCH先呼叫座席分机接通后再呼叫客户号码。这样做的好处是销售不需要先拨号再等接通系统全自动完成。软电话终端通过WebSocket上报ringing、answer、hangup三个状态事件同时附上FreeSWITCH生成的call_id作为关联键。通话结束后FreeSWITCH通过事件钩子把通话详单写入一张中间表内容包括起止时间、时长、方向、录音文件地址。DeskcommCRM的后台消费者比对WebSocket事件和话单事件把两路数据合并成一条完整的call.ended事件再写入通讯归档。这里比较关键的点是两路数据必须通过call_id关联。我们最开始只依赖FreeSWITCH话单但话单写入有几十秒延迟销售挂完电话立刻打开客户详情页看不到记录体验很差。加上WebSocket上报后通话结束一两秒内记录就能出现在页面上话单数据则作为最终校正项用来修正可能丢失的状态事件。3.2 邮箱接入IMAP抓取与编码乱码的隐形问题邮箱我们一开始踩了个大跟头。我们用的是IMAP协议全量拉取邮件然后写解析器提取正文和附件。第一版解析器上线没两天就发现客户发来的中文邮件经常乱码而且所有带附件的商务邮件都解析不出来。排查下来原因有两条。第一邮件头的Content-Type可能是text/html且没有标charset或者标的是gb2312但我们的解析代码默认按UTF-8读。第二邮件正文可能是quoted-printable编码也可能是base64编码不同客户端发出来不一样。光靠一种解析方式必然出错。后来我们统一走了一个流程先读MIME头部拿到Content-Type和charset然后按对应编码解码如果头部没写编码就用一个探测库去嗅探字节流。附件则是按Content-Disposition里的filename字段提取文件名但文件名在邮件传输过程中可能被编码成了RFC 2231格式的百分号编码也需要单独处理。这些都是在真实客户邮件里遇到的五花八门的格式不处理到位就会一直被偶尔一封乱码邮件骚扰。3.3 IM及网页客服接入回调事件去重与联系人识别企业微信/网页客服相对简单平台都会提供回调URL事件推过来通常是JSON。但这里的坑有两个。第一个是回调事件会重复推送。平台为了保证送达有时候同一个事件会推送来两三次。刚开始我们没做幂等导致客户消息在归档表里出现重复。解决方式是建了一张event_dedup表用(channel, platform_event_id)做唯一索引插入时捕获唯一键冲突就丢弃。第二个是从IM消息里识别客户身份。客户用企业微信加好友或者从网页端发起会话时我们能拿到的只有平台的open_id要和内部联系人匹配就得靠对接人在系统里主动绑定。我们在客服工作台的会话列表里做了一个关联客户按钮客服手动选择这个会话对应哪个联系人关联过一次之后后续所有该open_id的会话都会自动落到这个联系人的档案下面。这个方案虽然土但是可靠。平台的人脸识别、语义匹配这些花活在数据准确性面前都不如一次手动关联靠谱。4. 权限模型与数据边界客户数据系统的生死线客户通讯记录里包含手机号、微信ID、邮件地址和交易意向这些数据一旦泄露对一家小公司来说可能是毁灭性的。所以权限模型我们设计得很谨慎宁可麻烦一点也不能漏。4.1 数据权限范围客户、联系人、通讯记录、跟进记录四层隔离DeskcommCRM的权限模型是角色权限数据范围两个维度叠加。角色权限控制的是功能按钮比如谁能删除客户、谁能导出数据、谁能修改系统配置。数据范围控制的则是你登录系统后能看到哪几条客户记录。数据范围分三级范围说明适用角色仅本人只能看到自己创建的客户、自己参与的通讯记录普通销售、客服团队成员可以看到本部门/本组所有人的客户与通讯记录销售组长全员可以看到全公司所有客户与通讯记录管理员、老板这个三层隔离看起来简单实现时最容易出问题的地方是客户和通讯记录的权限判断不一致。比如一个客户是A创建的但B昨天给他打过电话那B能看到这条通话记录吗我们最终的规则是客户主记录归属决定查看权通讯记录跟着客户走但任何通讯记录涉及的参与者拨打人、接听人本身也有查看权。这个规则我们花了一周多才理清楚因为涉及多对多关系的可见性判断不能简单地用WHERE created_by ?来解决。4.2 敏感字段的加密存储与页面脱敏手机号、邮箱、微信号这类字段属于敏感字段。我们的做法是数据库里存AES-256加密后的密文业务查询层在普通列表页只返回脱敏值比如138****8000只有进入客户详情页且当前用户对该客户有完整查看权限时才解密返回完整号码。脱敏看起来是个小功能但容易出细节问题。比如通话记录列表里如果直接显示脱敏号码销售可能认不出这个客户到底是谁反而要来回跳转。我们的折中方案是列表页显示脱敏号码但挂一个一键拨号按钮点按钮直接通过软电话呼叫销售不需要在界面上看到完整号码。这样既不影响工作效率又降低了号码被截屏泄露的风险。4.3 导出审批与审计日志先有执行再有记录内鬼泄露很多时候不是恶意的而是顺手。销售为了做个报表把几千个客户电话号码导出去后来那个Excel文件被转发了几手谁也说不清楚。我们在导出功能上加了一道强制流程导出前必须填写用途且导出总量超过200条记录时必须由管理员审批。所有导出操作都会写审计日志记录操作人、时间、导出条件、导出行数、用途说明和审批人。同时所有高危操作——删除客户、修改联系人归属、合并客户、批量导入——都在审计日志里带上操作前后的数据快照。这样即使出问题也能在几分钟内定位到是谁在什么时间做了什么。这套权限体系上线后确实影响了一些操作便利性但换来了任何一次数据操作都有据可查的安全感。对业务系统来说数据安全不是功能是底线。5. 踩坑实录三个让我半夜起来改代码的问题系统上线三个月后进入真实业务压力测试阶段各种预料之外的问题陆续暴露。这里挑三个最有代表性的把排查过程完整还原出来比直接给答案有用得多。5.1 客户合并后通讯记录消失现象管理员在合并两个重复客户后被合并客户的通话记录在列表里不见了。查询数据库却能看到记录还在。排查过程第一反应是合并逻辑里更新了customer_id但可能没提交事务。查代码发现合并操作在事务里确实把归属联系人contact.customer_id和通讯记录communication_log.customer_id都做了更新事务也正常提交了。那问题出在哪继续排查发现前端的列表页查询语句里带了一个WHERE customer_id ? AND contact_id ?。合并前被合并客户customer_id200下面的联系人contact_id99合并后联系人被移到了customer_id100下但通讯记录的customer_id我们更新了contact_id没有同步更新。列表页用新的customer_id和旧的contact_id组合查自然查不到。根因合并逻辑更新了客户维度却遗漏了联系人维度的同步。本质上是数据一致性设计时没有把客户公司联系人作为一个整体来对待。修复合并操作改成两阶段先合并联系人把被合并客户下的所有联系人统一挂到保留客户下并记录一份old_contact_id到new_contact_id的映射然后基于这个映射批量更新通讯记录的联系人归属。验证合并不再丢记录而且因为合并前先做了联系人映射后续所有依赖联系人维度的统计也不会歪。5.2 外呼接通率统计偏低状态事件的乱序问题现象某天销售反馈系统统计的外呼接通率明显低于实际感受。他们觉得很多电话明明接通了系统却显示无人接听。排查过程先看原始事件流发现通话详单和WebSocket上报的事件顺序偶尔不一致。具体表现是通话结束事件先到达随后才到达通话开始事件。我们的事件消费者程序是顺序消费的先收到结束事件时发现没有对应的开始事件就直接标记为异常事件丢弃了。等开始事件到达后前面的结束事件已经被丢了好像这条通话根本没有发生过接通率自然偏低。根因FreeSWITCH事件队列分区和WebSocket上报走的不是同一条链路两路消息的时序无法保证。消费者程序默认了先有开始、后有结束没有处理乱序事件。修复在消费者层加了一个乱序缓冲窗口。具体是收到结束事件但没找到对应开始事件时先把结束事件放入一个延迟队列保留90秒。如果90秒内等到对应的开始事件就合并成完整记录如果始终没等到再进入异常队列由人工处理。验证接通率统计恢复正常后续又把缓冲时长从90秒调整到5分钟没有再出现过丢事件的情况。5.3 客户邮件解析少了一半正文现象部分客户来信在系统里只显示了一句话点进详情才能看到完整内容。排查过程前端列表页显示邮件正文摘要我们最初的做法是直接从正文取前80个字符。但有的邮件开头是回复关于报价事宜这种长标题80个字符里全是标题和引用的历史邮件真正的回复内容一个字都没显示。根因不是解析问题是摘要截取逻辑太粗暴。没有排除回复头、引用内容和签名区直接截前80字符自然效果很差。修复摘要提取前先做简单的邮件正文清理去掉开头的引用行去掉常见的签名关键词祝好此致等然后再取前100个字符做摘要。同时把打开全文按钮做得更明显让销售能一键看完整邮件。验证列表页摘要的可用性明显提升销售不再需要一个个点开邮件才能判断要不要跟进。这三个问题有一个共同特征都不是复杂的架构问题而是边界情况处理不到位。每次排查完后我都在想如果设计阶段能多花点时间想清楚事件乱序怎么办关联字段变更时其他维度怎么办这类问题后面可以少熬很多夜。6. 复盘如果现在重新做DeskcommCRM我会在哪些地方做不同选择系统上线稳定运行后我做过一次比较完整的复盘。有些决定当时看是合理的回头看其实有更好的解法。第一件会改变的事是软电话这块尽早用第三方云呼叫中心而不是自建FreeSWITCH。FreeSWITCH本身很强大但电话线路的稳定性、号码资源的申请、语音质量调优这些事每一样都要持续投入精力。如果当时的预算和团队状态允许直接对接云呼叫中心的能力会省下至少一个月的时间。这个建议只针对我们这种没有通讯行业背景的小团队如果是专门做通信的团队另当别论。第二件会改变的事是一开始就引入操作事件表的统一审计机制而不是上线两个月后才补。我们的审计日志是后来补上的导致前面有一段时期没有完整的操作留痕。建议一开始就在主服务里加一个中间件所有写操作自动记录操作人、操作时间、请求参数和响应结果。这个成本很低但能为后续追溯省下巨大的工作量。第三件会改变的事是把客户标签这类灵活属性设为可配置字段用JSON存储而不是模式变更来适应。我们早期为客户表加了很多布尔字段is_vip、is_urgent、is_refund_pending。业务一变就要改表结构后来改成JSON字段存tags数组灵活度立刻上来了。这个教训在业务快速迭代期尤其明显。最后一件小事是给所有外部接口的调用都加上统一的超时与重试策略。我们踩过企业微信回调偶发超时导致消息处理延迟的问题后来统一封装了一层HTTP客户端的超时、重试、熔断逻辑问题才彻底根治。这些复盘不是否定当时的决策而是说明一个道理系统设计永远是在当时的信息和资源约束下做最优选择。DeskcommCRM对一家小公司来说称不上多么精巧的系统但它验证了一件值得分享的事——把通讯数据和业务数据放在同一套体系里管理哪怕用很朴素的架构和方案也能给团队带来效率上的显著提升。如果这篇文章能帮遇到类似问题的同行少走几步弯路那就很值了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →