这事得从一次周五复盘说起。当时我们团队的销售挨个汇报本周跟进的客户,说到某个重点客户时,他翻了三分钟聊天记录,又去邮箱里搜了两封附件,最后也没能准确说出对方上次到底对哪个方案表达了犹豫。那一刻我就意识到,客户信息分散在IM、邮件、通话记录里的问题,已经不是在拖效率的后腿,而是在直接制造风险。后来我利用业余时间做了个东西,名字就叫DeskcommCRM。它的核心思路很简单:把团队日常和客户发生的每一次沟通,不管是从企业微信、企业邮箱还是电话网关进来的,都自动归并到对应的客户档案里,变成一个按时间排列的完整上下文。
这个项目做下来,最深的感受是:CRM的价值不在表单里,而在时间线上。传统CRM要人去填跟进记录,填着填着就成了应付差事;而DeskcommCRM这类思路,是把"聊天记录""邮件往来""通话录音"当成第一手资料自动归档,人只需要在上面做补充,整个客户旅程才有机会完整。
这篇文章我会把当时为什么选这套自建方案而不买现成产品、通信接入层怎么设计、客户聚合去重的逻辑、搜索和时间线的实现,以及踩过的几个大坑,通通过一遍。如果你正在被"客户信息散落各地"这件事折磨,或者正准备做类似的消息集成、客服工作台、销售辅助系统,这应该是一篇可以直接抄作业的实操记录。
1. 为什么我决定自己写DeskcommCRM,而不是直接在现成CRM上加插件
1.1 数据割裂才是真问题,销售漏斗反而不是
我们最初也试过两套主流的商用CRM,用了一两个月就发现,它们解决得最好的是管理动作:线索怎么分配、商机阶段怎么流转、报表怎么出。但最让人头疼的数据割裂问题,它们反而帮不上太多忙。
销售每天最多的时间花在哪里?在企业微信里和客户来回沟通,在邮箱里和客户确认合同细节,拿起电话和人聊需求。这些颗粒度极细的沟通内容,几乎不会自动进入CRM。商用CRM当然也有集成能力,但要么是企业微信会话存档这种增值模块要额外按坐席付费,要么是邮件绑定只支持最基础的IMAP拉取,通话录音更是得靠第三方中继。算下来一个十人销售团队,光补齐这些集成的年费就够再招半个人了。
更要命的是,就算把这些数据都导入进去,商用系统里的客户档案和沟通记录之间往往是割裂的。一条聊天记录挂在某个联系人下面,一封邮件挂在另一个联系人下面,要是客户中途换了联系人或邮箱,前面的历史就找不齐了。这让我意识到,我们缺的不是一个记录工具,而是一个能自动把分散沟通攒成上下文的聚合层。
1.2 自建的边界:只做最核心的"通信沉淀"一件事
我给自己划了一条边界:DeskcommCRM不自研IM,不自研邮箱,不自研电话系统,它只做一件事——把外部渠道的沟通数据接进来,清洗、去重、聚合,然后以客户为单位呈现。
这条边界很重要。有段时间我想着把待办任务、合同审批也塞进来,后来发现每多一个模块,就要多做一套权限、多维护一批字段,而真正让团队离不开的其实只有"某客户到底聊到哪了"这一个痛点。把兵力集中在通信接入层和数据聚合层,才可能在业余时间内把一个核心闭环打穿。
技术选型上我也没有搞得太重。后端用的Python FastAPI,数据库是PostgreSQL,消息检索这块数据量没上去之前先用PostgreSQL的FTS,后续要是真大到扛不住,再单独上Elasticsearch也来得及。前端用Vue3做了个管理台,桌面端套了个Tauri壳,方便销售常驻在通知栏。这套组合的好处是迭代快,我一个人也能维护。
1.3 这个项目适合谁参考
如果你属于下面某类情况,DeskcommCRM的这套设计会很对胃口:
- 团队依赖企业微信/微信/邮件/电话和客户沟通,但客户信息散落各处,想低成本做一个统一时间线;
- 你所在的公司已经有CRM,但销售根本不爱填跟进记录,管理层想看真实客户进展;
- 你想做客服工作台、SCRM、会话存档分析之类的前置数据整合层;
- 你正在学习消息集成、事件驱动架构,想找一个贴近业务的不错的练手场景。
反过来,如果你的目标是复杂的销售流程自动化、报价审批、业绩核算,那一上来自建CRM就不太划算,老老实实买成熟产品更省心。DeskcommCRM的定位从来不是替代CRM,而是给CRM补上"前端通信数据"这块拼图。
2. 通信接入层的选型与对接逻辑:企微会话、邮件、电话录音如何变成一条条结构化事件
2.1 统一事件模型是一切的前提
接入多个渠道最容易犯的错,就是每个渠道拉回来什么样的原始数据,就直接往数据库里塞。等你想做统一时间线的时候,会发现企微的文本消息、邮件的HTML正文、电话录音的转写文本,结构完全对不上,前端渲染只能写一堆if-else。
我在DeskcommCRM里先定义了一个统一的事件模型,所有渠道的数据在入库前都必须清洗成这个结构。核心字段大概是这样的JSON:
{ "event_id": "msg_20240918_ab12cd34", "channel": "wecom", "direction": "inbound", "occurred_at": "2024-09-18T10:23:11+08:00", "customer_refs": [ {"type": "wecom_userid", "value": "zhangsan"}, {"type": "email", "value": "zhangsan@example.com"} ], "staff_refs": [ {"type": "wecom_userid", "value": "sales_li"} ], "content_type": "text", "content": "上次你提到的那个报价方案,我们再开个会确认一下", "attachments": [], "raw_link": "https://..." }event_id不是渠道给的ID,而是我自己生成的"渠道+渠道内消息ID+SHA256截断"的复合ID,这样做是为了后面去重。customer_refs和staff_refs是关键,它们是聚合层的输入,标记了这条事件关联到哪个客户标识、哪个员工标识。occurred_at必须带时区,邮件和企微消息的时间规范不一样,不带时区后面排序会乱掉。
2.2 企业微信会话:回调解密和会话存档的取舍
企微接入市面上通常有两条路。一条是普通回调,就是客户在群里@销售或给销售发消息时,企业微信服务器往我们的回调地址推一条通知,我们拿到之后可以再调用API拉取消息详情。另一条是会话存档,需要企业认证、配置可信IP、购置客户端,而且要用户或管理员同意,能拿到全文,合规性要求很高。
DeskcommCRM早期做的是普通回调,因为它接入成本最低。但普通回调有个限制:只能拿到那个时刻之后的新消息,历史消息需要自己从企微的API按时间范围拉取,而且接口有频率限制。我的处理方式是:启动时做一次全量同步,跑一个定时任务每5分钟增量拉取一次,再在回调里收到新消息时实时触发一次针对性的增量同步。这样既不会漏消息,也能保证实时性。
回调数据的处理有一步很关键:验证消息签名。企微回调URL需要配置Token和EncodingAESKey,所有推送都会带签名,拿不到正确的签名就不能通过验证。最简单的方式是直接用官方SDK里的解密函数,千万别自己去解析AES,里面有很多padding和随机串的细节,踩坑成本很高。解密之后你会得到一个XML或JSON结构,里面包含FromUserName、ToUserName、Content等字段。我从这个结构里要提取的是:客户企微ID、员工企微ID、消息类型、消息内容、消息时间。
2.3 邮件接入:IMAP的全量扫描和增量游标
邮件这块我选了IMAP而不是企业微信邮箱的API,原因是IMAP是协议级的,基本任何邮箱服务商都支持,不用为每个服务商写一套对接。
但IMAP有个问题:它天生是"拉取"模型,没有推送机制。我们的做法是每60秒对收件箱做一次增量检查。增量检查的基准是邮件的UID,通过UID SEARCH UID > 上次最大UID这种命令,能很快捞出新邮件。不过UEK有个比较隐蔽的坑,就是某些邮箱服务商在多设备同时访问时UID稳定性会有问题,因此我额外加了一个兜底:每次全量拉最近30天的邮件,和库里的Message-ID比对去重。
邮件清洗要比企微复杂得多。正文要先从HTML里抽取纯文本,去掉页眉页脚、免责声明、回复引用的前文;附件要单独存到对象存储里,然后在事件模型的attachments字段挂上对象地址。customer_refs要从两个地方提取:一是发件人和收件人的邮件地址,二是邮件正文签名区里的手机号或姓名,这个我们用正则和预设的销售签名模板来解析。
2.4 电话录音:从PBX到转写文本
电话接入依赖于公司的PBX系统。我们的情况是,销售用的是一个支持SIP的IP电话交换机,所有通话都有CDR话单和录音文件,支持通过FTP或API把录音文件推送给第三方系统。DeskcommCRM在PBX侧挂了一个"外部应用"订阅,每次通话结束,PBX会往我们这边推一条Webhook,里面带主叫号码、被叫号码、通话开始时间、通话时长、录音文件URL。
拿到录音文件之后,先用语音转写服务把音频转成文本。这一步耗时比较长,我没有把它放在Webhook的同步链路里,而是丢进一个异步任务队列(我用的Celery + Redis),转写完成之后再更新对应的事件记录。转写文本虽然偶尔有错别字,但作为时间线上让销售快速回忆起"这通电话聊了什么"是完全够用的。
这里又回到统一事件模型:电话事件没有content,但有content_type: "call_transcript",前端看到这个类型就会渲染成"通话录音+转写文本+时长"的卡片,而不是普通文本气泡。渠道差异在存储层被抹平,展示层才能统一。
3. 客户聚合与去重:如何把四面八方来的消息对账到同一个客户档案上
3.1 客户标识的主数据模型
通信渠道的数据进来了,但如果不知道每一条消息属于哪个客户,那时间线就无从谈起。这是DeskcommCRM整个项目里最烧脑的部分。
我用了"主数据 + 标识映射表"的模型。主表存客户档案,不关心他的微信ID或邮箱是什么。标识映射表存"客户ID -> 渠道标识"的多对多关系,一个客户可以有多个企微ID、多个邮箱、多个手机号。每进来一条事件,就从customer_refs里逐一反查标识映射表,能匹配到就归到对应客户ID,匹配不到就进入"待认领池"。
关键来了:标识映射关系不是只靠人工维护的,我更依赖自动建议。系统会按照下面这张表的优先级尝试建立绑定,注意每种都是有置信度门槛的,不是匹配上就立即合并。
| 匹配方式 | 依据 | 置信度 | 处理方式 |
|---|---|---|---|
| 邮箱完全匹配 | 客户邮件地址相同 | 高 | 自动绑定 |
| 企微ID匹配 | 会话中客户企微ID出现过 | 高 | 自动绑定 |
| 手机号匹配 | 电话主叫号码或短信验证码 | 高 | 自动绑定 |
| 同域邮箱推断 | 公司域名相同但用户名不同 | 中 | 提示人工确认 |
| 会话双方关联 | 同一个员工在不同渠道和同一个人聊,且聊天内容含相同邮箱 | 中 | 提示人工确认 |
| 姓名+公司名相似 | 落库的客户姓名和公司名文本相似 | 低 | 进待认领池 |
3.2 一个电话+一个邮件怎么归到同一个客户
举个例子。销售小李收到了老客户王芳的一封邮件,邮件地址是wangfang@example.com,系统里没有这个地址,于是进待认领池。过了两天,王芳用企业微信给小李发消息说"邮箱里发的那个附件我收到了",企微ID是wangfang_wx,系统在待认领池里发现这个名字相似,但它不会直接合并——它先把两条事件都展示在"疑似同一人"页面,小李瞄一眼,确认"对,就是同一个王芳",点一下合并,两条事件挂在同一个客户ID下。
这个人工确认的环节非常重要。如果你踩过自动去重过度合并的坑,会知道两个客户因为手机号转发而串档有多崩溃。串档不仅数据乱,还可能把A客户的商务信息误发给B客户,这是合规红线。所以我的原则是:高置信度(邮箱完全匹配、企微ID匹配)可以自动绑定但不能跨客户合并;中低置信度一律给建议、让人点头之后才动主数据。
3.3 兜底方案:待认领池和主数据生命周期
待认领池不是个临时垃圾场,我把它做成了独立视图。销售每天来上班,可以花两分钟看有没有"新线索待领"。如果一个待认领事件30天内没有被认领,它仍会一直躺着,只是排名权重下降。此外,DeskcommCRM允许把一个客户直接归档(比如对方明确说不再合作了),归档后新来的事件如果匹配到这个客户ID,会重新唤醒它,并提醒归属销售。这个生命周期管理虽然简单,但在真实业务里很实用,因为客户沉默一段时间后又回来咨询是很常见的事。
4. 360度时间线与全局搜索的实现思路
4.1 时间线的查询设计
有了干净的数据,时间线功能才有意义。我不想每次打开客户详情页就把这个客户所有事件全部加载出来,因为有些客户消息量能到几千条,全量加载会让页面卡死初始化。
做法是分页 + 游标。客户详情页首次只加载最近50条事件,向下滚动时,通过occurred_at < 当前最早一条的occurred_at这个条件向后翻。这里有个细节:PostgreSQL对时间范围索引的查询性能很好,但前提是索引要设计成(客户ID, 发生时间 DESC)的复合索引,而不是单一时间索引或单一客户ID索引。我实际测试下来,百万级事件量下,复合索引的响应时间基本在80毫秒以内,而拆成两个单列索引有时候会慢十倍不止。
时间线上还会有一种特殊需求:合并展示"客户在多个渠道的同一天活动"。比如某客户上午在微信上问了个问题,下午给你回了封邮件,时间线里应该按时间顺序排成两个卡片,中间不能插入其他客户的事件。这个在查询层面天然满足,因为我都是按客户ID + 时间过滤的。
4.2 轻量搜索方案:PostgreSQL全文检索
全局搜索是我一开始没重视、后来发现有奇效的功能。销售经常会问"我们之前是不是给某客户提过一个返点方案",这时候如果没有全文搜索,就只能一个个点进去翻,体验非常差。
我先用PostgreSQL的FTS5能力做了初版。具体做法是:在事件表上加一个生成列,把content、channel、staff_refs对应的员工姓名拼成一个可搜索的search_document,然后建GIN索引。查询时用to_tsquery('simple', 关键词)去匹配,中文场景下用默认的simple分词其实不行,我用了pg_jieba扩展做中文分词。效果在几十万条事件量级下已经很能打了,平均查询时间在120毫秒左右。
搜索结果的排序不能只用相关度,还要考虑时间。相关性高但五年前的一条老消息,远不如三个月前的那条重要。DeskcommCRM的排序公式是:rank = ts_rank_cd(search_document, query) * 0.7 + recency_bonus * 0.3,recency_bonus按事件距离当前天数做平滑衰减。这个公式不复杂,但实际用下来搜索命中准确率提升明显。
4.3 事件回放与"客户一句话摘记"
除了列表式时间线,我还加了一个"回放模式"。每次复盘客户前,销售可以进回放模式,系统按时间顺序逐条播放这个客户所有渠道的沟通事件,相当于给这个客户拉通了一整段连续剧。有人可能会说,这跟滚动看时间线有什么区别?区别在于回放模式会自动跳过附件、系统通知等低信噪比事件,只保留真实对话内容,并且每条之间会显示时间间隔(隔了多少小时多少天),这能帮销售快速感知到一段客户关系的节奏变化。
另外每个客户页上我放了一个自动生成的"一句话摘记",由最近七天内出现频率最高的关键词提炼而来。这个功能其实很鸡贼,灵感来源于查看转写文本时发现,一个客户一周内反复提"部署周期"和"预算",那说明他当前最关心的问题就是这两个。这个摘记不需要很智能,能起到提醒作用就够了,真正的判断还是在销售自己脑子里。
5. 我在落地过程中踩过的坑:从回调幂等到全文检索变慢
5.1 企业微信回调的重放与幂等
上线第一周就翻了一次车。回调接口被企微服务器同时推了同一批消息两次,结果时间线里每条消息出现了两条一模一样的事件。查下来发现,问题出在我对event_id的生成上。一开始我用的是"渠道内消息ID"直接当成event_id,企微在重试推送时消息ID是相同的,我自认为自己做了去重,但因为代码里的入库逻辑是先检查event_id是否存在再插入,而这两次请求恰好并发进来,检查时都发现不存在,于是同时插入了两条。
修复方案分两步。第一步,把event_id的生成规则改成"渠道 + 渠道内消息ID + SHA256截断",让它稳定不可变。第二步,在数据库层面加唯一约束,而不是靠应用层先查后插。插入的时候用INSERT ... ON CONFLICT DO NOTHING,从根上杜绝并发重放。这套思路后来也被我用到了邮件同步和PBX Webhook上,不管上游推多少次,数据库的幂等约束兜底。
5.2 邮件同步的时区混乱和Message-ID缺失
邮件这块遇到的坑比较隐蔽。IMAP拉回来的邮件头里,Date字段格式五花八门:有的带时区,有的只是UTC,有的干脆写了本地时间却没有时区偏移。如果直接用这个时间去排序,时间线会乱,比如一封实际是下午三点发的邮件,显示成了早上十点。
我的处理办法是:解析邮件头的时候,优先取Date,但必须通过email.utils.parsedate_to_datetime把它转成 aware 的 datetime,再统一转成UTC存储。如果Date解析失败,就退回用Received头链里第一个有效的时间戳。这一步很考验细节,但做对了省心很多。
另外还有个坑:不是所有邮件都有Message-ID。有些客户公司自建邮件服务器,发出来的邮件不带Message-ID头,导致用Message-ID去重会失效。我的兜底方案是:如果Message-ID不存在,就用"发件人+收件人+主题+日期(精确到分钟)"拼接一个本地ID,这样重放时也能稳定去重。
5.3 全文检索从秒级到毫秒级的调整
初版搜索用得很爽,但到四万多条事件的时候,某些关键词查询突然变得很慢,有的要两秒多。我把查询计划捞出来一看,发现虽然建了GIN索引,但to_tsquery构造出来的查询语法在某些关键词下会被展开成超长的OR条件,再加上我正在做的ts_rank_cd排序,PostgreSQL干脆选择了全表扫描。
解决方法是把关键词先用plainto_tsquery而不是to_tsquery转换。to_tsquery会把空格当AND,而且不支持单字中文查询;plainto_tsquery会把输入变成一个短语查询,对中文更友好。再有就是我在查询前先做一次最少词数限制,关键词少于两个字就直接拒绝搜索,避免用户输个"客"字就把全库打一遍。改完之后,四十万条事件量的典型查询稳定在150毫秒内。
5.4 电话转写的异步冲突
电话录音转写是异步执行的,于是出现了一个竞态:Webhook先把通话事件插入库,状态是"录音转写中";转写完成后,异步任务去更新同一条事件的内容。如果销售刚好在我转写完成前打开了这条事件,看到的是"音频文件已上传,转写中",也算正常。
真正的问题发生在转写服务偶发超时重试的时候。第一次转写结果回来了,写进库里;紧接着重试任务又跑了一次,写的是同一份转写文本,但因为我在更新逻辑里用了全字段覆盖,把occurred_at都改成重试时的当前时间了,客户时间线硬生生被往后挪了几秒。从那以后,所有异步更新事件内容的SQL只更新content和status两个字段,绝不碰occurred_at和event_id。这是事件溯源类系统最该记住的规矩:事件事件一旦写入,它的业务时间字段就应该只读。
6. 现在的DeskcommCRM长什么样,以及我对后续扩展的想法
6.1 写给自己的复盘
现在的DeskcommCRM已经跑稳了半年多,数据库里有差不多四十万条各渠道事件,销售团队每天上班第一件事就是打开客户时间线看新增了什么。团队最真切的感受不是"多了个系统负担",而是"终于不用自己拼聊天记录做客户汇报了"。
从架构上看,它其实没什么高深的东西。核心就是一条数据流水线:各种渠道的Webhook/定时任务把事件推进来,清洗、标准化、去重、聚合,然后落到PostgreSQL,前端通过API按需查询。业务规则不算复杂,但每一层都卡得比较死,尤其是幂等和时间保持这两条铁律,守住了它们,数据才不会烂。
6.2 仍然在折腾的几个方向
转写文本的关键词提炼目前只是初版,用的是简单词频,很多同义词合并不了。我下一步打算引入一个轻量的分类模型,把客户消息自动打上"询价、售后、投诉、付款、闲聊"之类标签,然后基于标签做更细的统计。
通知提醒也还有优化空间。现在给销售推的"客户新消息"通知粒度太粗,没有区分消息紧急程度。期望是能结合历史响应时间,评估出"这条消息超过多久没回会导致客户不满",然后按紧急程度分级提醒。这需要积累更多行为数据来判断,但方向我觉得是值得做的。
最后说一个很多人问过的问题:DeskcommCRM能直接拿出去卖吗?说实话,我目前更愿意把它定位为团队内部的"通信数据底座"。每个团队的客户触达渠道、权限体系、字段规则都太不一样了,想把整套东西产品化,前期需要投进去的打磨工作比我写这部分代码多得多。但如果你的团队也长期被"消息和客户对不上账"困扰,在内部搭一套类似的底座,性价比真的很高。