做客服或者售前支持的同学,应该都有过这种经历:客户在微信里问一句,在邮件里补一句,又在留言板上提个工单,结果同一个客户的消息散落在三四个后台里,谁都不敢拍板说“这事我来跟”。我这次折腾的DeskcommCRM,就是想把这堆乱账收拢到一个桌面上,让坐席不用来回切窗口、翻聊天记录,而是所有沟通记录自动归到同一个客户名下,工单状态和消息记录实时联动。这个项目适合手上管着客服团队、想做一套轻量客户沟通中台,但又不想被重型CRM套牢的团队参考,也适合个人开发者拿来学一下多会话渠道集成的设计思路。
1. DeskcommCRM 整体设计与核心思路
1.1 这个项目解决什么问题
先说痛点。很多团队并不是没有CRM,而是CRM太多了:销售用一套,客服用一套,售后工单又挂在另一套系统里。客户打电话进来,客服要先去订单系统查购买记录,再去工单系统看历史问题,最后切到聊天工具回复消息。一个简单问题要来回切换四五个系统,客户等得不耐烦,客服自己也被搞得很疲惫。
DeskcommCRM 的核心定位是“以沟通为主线”的桌面前台,减少录入成本。它不打算取代你已有的财务、库存或者企业资源计划系统,而是把客户沟通的入口统一起来,比如网页在线聊天、邮件、表单留言,将来如果想接企业微信或者钉钉,也能通过统一的渠道层接进去。所有消息进系统后,自动关联到对应的客户档案和工单记录,坐席在一个界面里完成接待、转交、跟进和归档。
另一个想解决的问题是“信息断层”。很多团队用飞书或者企业微信对接外部客户,默认的聊天记录只有群成员才能看到,客户一旦加进来,之前发生了什么新人完全不知道。DeskcommCRM 把整个会话历史以文本化、可检索的方式落到自己库里,每个坐席接手都能看到前因后果,新人培训成本和交接成本都大幅降低。
1.2 技术选型:为什么没有直接买现成的
选择自研而不是采购现成产品,主要出于三个考量:一是成本,团队当时接入的渠道比较杂,按坐席数授权的话,一年下来并不便宜;二是定制空间,现成产品对“某个客户在某渠道的消息如何匹配到已有用户”这类逻辑是黑盒,出问题很难排查;三是数据归属,公司想保留一份完整的客户沟通数据,方便后续做质检和统计,数据在自己系统里会踏实很多。
技术栈上,后端我选了 Python 的 FastAPI 作为主服务。原因不复杂:异步支持好,处理消息推送和 Webhook 这类IO密集型场景很顺手;自带 OpenAPI 接口文档,前端联调时可以直接对着文档看字段。前端用的是 Vue 3 + Element Plus,表格、抽屉、弹窗这类后台管理组件都有现成的,开发速度比从零写快得多。数据库用的 PostgreSQL,虽然初期数据量不大,但考虑到后面要用 JSON 字段存渠道原始消息、用全文检索查客户历史,PostgreSQL 比 MySQL 顺手。消息推送需要一个中间层,当时图省事直接用了 Redis 的发布订阅做站内通知,坐席在线时能实时收到新消息提醒。
注意:如果团队里没有人熟 PostgreSQL,其实 MySQL 也能起步,只是后面做全文检索时要额外引入别的方案。技术上没有绝对的对错,重点是先把业务逻辑跑通。
2. 核心数据模型与关键设计
2.1 客户画像表:一切以客户ID为中心
DeskcommCRM 的数据模型设计里,最重要的一张表就是客户表。所有渠道的联系人,最终都要尝试映射到某一个客户档案上。比如网页聊天里用户填写的邮箱、表单留言里的手机号、邮件发件人的地址,这些都是定位客户的线索。
我建的客户表核心字段大致是这样:
CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR(100), email VARCHAR(255), phone VARCHAR(32), company VARCHAR(150), source_channel VARCHAR(30), unified_id VARCHAR(64) UNIQUE, extra JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_customers_email ON customers (email); CREATE INDEX idx_customers_phone ON customers (phone);设计上要注意一个点:不要用邮箱或者手机号直接当主键。原因是同一客户可能有多个邮箱、多个手机号,未来还可能接入新的渠道,主键一旦定死后面很难改。用自增 id 作主键,再加一个 unified_id 字段保存业务层的统一标识,这样既保证数据库性能,又给业务留了扩展空间。extra 字段用来存渠道特有信息,比如在线聊天里记录的城市、机型,邮件渠道里记录的语言偏好,这些信息放在主表会污染结构,放 JSONB 最合适。
客户合并是另一个必须提前考虑的问题。初期系统里很容易出现同一个客户在网页聊天留了个邮箱、在工单里留了个手机号,系统不知道两者是同一个人,于是生成了两条档案。后续我会在消息路由时做“碰库”逻辑:新来的消息先查手机号和邮箱,匹配到已有客户就直接挂上去,匹配不到才新建。这个策略简单粗暴但很实用,比一开始就上复杂的实体解析算法靠谱得多。
2.2 工单与会话:两条线互相引用
CRM 系统没有工单和会话是转不动的。我的设计里,会话(conversation)是“客户和坐席之间的一次连续沟通过程”,工单(ticket)则是“需要解决的问题项”。会话偏过程,工单偏结果,两者用外键关联。
CREATE TABLE conversations ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT REFERENCES customers(id), channel VARCHAR(30) NOT NULL, channel_thread_id VARCHAR(128), status VARCHAR(20) DEFAULT 'open', -- open / pending / closed assigned_agent_id BIGINT, started_at TIMESTAMPTZ DEFAULT now(), closed_at TIMESTAMPTZ, metadata JSONB DEFAULT '{}' ); CREATE TABLE tickets ( id BIGSERIAL PRIMARY KEY, conversation_id BIGINT REFERENCES conversations(id), customer_id BIGINT REFERENCES customers(id), subject VARCHAR(255), priority VARCHAR(10) DEFAULT 'normal', status VARCHAR(20) DEFAULT 'open', -- open / in_progress / resolved / closed assignee_id BIGINT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() );这样设计的好处是,坐席在接待时不用关心“这个客户之前开了多少工单”,他只需要看当前会话关联的工单历史和客户历史,就可以快速了解上下文。比如客户新发来一条消息,系统通过邮箱定位到老档案之后,自动把该客户所有待处理工单列表带到坐席工作台上,坐席一眼就知道这位是“老熟人”,还有两单没解决。
会话状态和工单状态不强制同步,这是我有意保留的灵活度。客户发来一条“上次那个问题还没好”,坐席可以直接把已有工单状态从“已解决”重新打开,而不需要新开一个会话。这更符合真实服务场景,也避免了不少操作上的死板限制。
2.3 渠道接入层:对外统一协议
渠道接入是整个项目里最考验抽象能力的模块。网页在线聊天、邮件、客户留言表单,这三类形态差别很大:在线聊天是双向实时交互,邮件是有来有回的文字记录,表单基本是单向的提交。但站在 CRM 的视角,它们本质上是同一种东西——客户说了几句话,我们需要承接并产生回复。
所以我给渠道层定义了一套最小协议:
class InboundMessage(BaseModel): channel: str channel_thread_id: str customer_email: str | None = None customer_phone: str | None = None customer_name: str | None = None content: str content_type: str = "text" raw_payload: dict = {}每个渠道接进来时,只需要实现一个适配器,把渠道自带的数据格式翻译成这个统一格式,然后交给消息路由中心。路由中心做三件事:解析客户身份、匹配或创建会话、按规则分配坐席。有了这层适配,未来加一个新渠道就只是写一个适配器的事,不用动主流程。
这一步设计上的教训是:不要把某个渠道的特殊字段直接写进主表。比如邮件渠道的in_reply_to字段对于一个网页聊天消息来说毫无意义,如果所有渠道共用一个超大宽表,字段越加越多、维护成本指数上升。正确的做法是把特殊信息全放进raw_payload,主流程只消费协议里定义好的通用字段。
3. 实操过程:核心链路是怎么跑通的
3.1 先搭一个能处理入站消息的API
整个项目我建议从“入站消息”开始做,因为这是所有业务的开端。没有客户消息,就不会有会话,也不会有工单。我先把 FastAPI 服务搭起来,定义好消息接收端点,再把路由中心放进去。
@app.post("/api/v1/messages/inbound") async def inbound_message(msg: InboundMessage, background_tasks: BackgroundTasks): customer_id = await customer_resolver.resolve(msg.channel, msg) conversation = await conversation_manager.get_or_create( channel=msg.channel, channel_thread_id=msg.channel_thread_id, customer_id=customer_id ) message = await message_repo.create( conversation_id=conversation.id, direction="inbound", content=msg.content, raw_payload=msg.raw_payload ) await event_bus.publish("message.created", { "conversation_id": conversation.id, "message_id": message.id, "customer_id": customer_id }) return {"ok": True}这里的customer_resolver是客户识别的核心。它内部按优先级做判断:先看channel_thread_id能不能直接命中已有会话;命中不了再看手机号/邮箱能否匹配已有客户;如果都没有就新建客户。注意顺序不能乱,因为老客户再次发消息的会话可能已经关闭,但渠道自带的线程 ID 会变。比如在线聊天每次打开页面都会生成一个新的会话 ID,不能拿它作为持久身份。
BackgroundTasks 是 FastAPI 的轻量异步任务方案,我在项目早期用它将通知推送、自动回复这类操作放到后台执行,这样接口响应不会因为推送服务变慢而卡住。等业务量上来了,建议换成 Celery 或者 AWS SQS 这类独立队列服务,调试起来更可控,也方便失败重试。
3.2 自动分配坐席:按负载而不是按人轮流
坐席分配如果做不好,就会出现“新客户没人管,老客户被人抢”的局面。我第一版用了最简单的轮询,谁空闲分配给谁。后来在实际使用中发现轮询有个问题:如果某个坐席正在处理一个非常复杂的工单,长时间没关会话,系统还是会不断给他推新客户,导致他负载越来越高,客户等待时间也越来越长。
所以我调整了分配策略:按活动会话数做加权分配。系统优先把新消息推给“当前活动会话数最少”的坐席,同时给每个坐席设置一个最大活动会话上限,比如 8 个。超过上限就不再分配新客户,直到他把部分会话关闭或转交。这个方案虽然简单,但对小型客服团队来说效果立竿见影,客户明显感觉响应快了。
async def pick_agent(assignable_agents: list[int]) -> int | None: counts = await redis_client.mget([f"agent:{aid}:active_count" for aid in assignable_agents]) candidates = [ (aid, int(c or 0)) for aid, c in zip(assignable_agents, counts) if int(c or 0) < MAX_ACTIVE_SESSIONS ] if not candidates: return None candidates.sort(key=lambda x: x[1]) return candidates[0][0]坐席的工作台是轮询还是实时推送?我选了 WebSocket。原因很简单:客服这个场景对消息到达的实时性要求很高,客户发一句话,坐席应该在几百毫秒内看到。用轮询虽然实现简单,但体验差,而且高频轮询会给数据库带来不必要的压力。WebSocket 连接的管理我放在了一个独立的服务节点上,通过 Redis 发布订阅把消息事件广播到所有前端,这样即使你有多个后端实例,也能保证消息推送不重复、不丢失。
3.3 Webhook 回调与实时状态同步
业务跑起来之后,有一个需求马上来了:当客户在网页聊天里发消息时,坐席工作台要立刻响铃弹窗。我一开始只做了入站存储,没做推送,结果客服全靠手动刷新页面,被吐槽了好几次。后来用 Webhook 回调封装了一层“事件通知”,把消息创建、坐席分配、会话关闭三类事件推给前端。
设计 Webhook 的时候我踩过几个坑。第一个是重试机制。渠道服务调用你的 Webhook 失败时会重试,如果我们的接口不是幂等的,同一消息会被创建两次,客户记录里就出现重复消息。解决办法是在消息表上加一个渠道线程ID加消息ID的组合唯一索引,重复投递直接报错忽略。
第二个是回调超时。接收端逻辑如果太重,比如发一封欢迎邮件、创建工单、通知坐席,这三个操作全在一个接口里做,渠道那边很快会超时然后重试,形成恶性循环。我的做法是:Webhook 接口只做“校验、去重、写一条待处理消息”,实际业务处理全部放入后台任务。这样接口响应稳定在 100ms 以内,渠道服务不会再触发重试。
UNIQUE_THREAD_MESSAGE = "uq_thread_message" # conversation_id + channel_message_id 唯一约束3.4 工作台界面该展示什么
界面设计上,我坚持一个原则:客户进来先看到客户,其次才是消息。很多CRM工作台上来就是一排消息列表,点进去才看客户信息。但对于售前咨询场景,坐席更关心的是“这位客户是谁、看过什么、买过什么、之前问过什么”。所以工作台左侧是会话列表,中间是消息流,右边是客户侧边栏,呈现客户基础信息、历史工单、历史会话摘要,以及当前会话的完整正文。
客户侧边栏我做得比较重。它除了展示客户姓名、电话、邮箱之外,还聚合了四块内容:当前待处理工单、最近三次沟通记录、最近一次下单金额,以及客户标签。这些信息帮助坐席在回复前快速做判断:这是一个询价的潜客,还是一个售后问题的老客户?不同结论对应的应对话术完全不同。
前端消息流我用的是虚拟滚动。消息多的时候,一次性渲染几千条 DOM 节点会把浏览器卡死。虚拟滚动只渲染当前可视区域附近的内容,滚动时动态加载,基本能保证消息量上万也不会明显卡顿。这个优化是在压测时发现的,当时用脚本给一个会话塞了一万条消息,普通渲染直接白屏,虚拟滚动之后流畅了很多。
4. 常见问题与排查技巧实录
4.1 会话状态不一致:客户已收到回复,工单还是待处理
这个问题最有迷惑性。现象是坐席明明在会话里回复了客户,但对应工单的状态仍停留在待处理,客户再来催时坐席也搞不清状况。排查后发现根因在业务代码里:回复消息和更新工单状态是两个独立操作,前者成功、后者因为一个字段校验错误被回滚了,导致数据不一致。
解决办法有两个层面。短期内在更新工单的地方做了补偿机制:每次坐席发送回复时,检查关联工单状态,如果是待处理就自动置为处理中;并且处理完消息之后,再发一次“工单状态同步”事件,保证最终一致。长期来看,这类强关联操作建议放到事务里或者用工作流编排,比如用 Celery 的任务链按顺序执行,任何一个环节失败就进入重试队列。
我的建议是:第一版不要追求分布式事务,那是给大型系统准备的。先把补偿任务写好,保证数据最终对齐,远比实现一套两阶段提交可靠得多。
4.2 客户重复建档:同一个手机号,两条客户记录
新系统上线一周,数据库里就出现了两条记录:一条来自网页聊天,客户填了邮箱;另一条来自售后表单,客户留了手机号。系统分别建了两个客户档案,但实际上是同一个人的概率极高。
排查过程分三步。第一步,查日志确认两个渠道都没有传完整的客户标识;第二步,看解析规则,发现邮箱匹配了、手机号没匹配;第三步,做合并。当时我写过一个一次性的数据清洗脚本,按手机号和邮箱分组,把重复档案合并到最新一条记录上,并重新绑定工单和会话的外键。
后续我做的事是在系统里增加一个“疑似重复”页面,规则很简单:手机号相同但客户ID不同,或邮箱相同但客户ID不同,就在列表里标红。坐席确认是同一人后,手工点合并,系统会把历史工单、会话、消息全部转移到主档案下。这个功能上线后,重复建档问题基本被管控在可接受范围内。
提示:合并客户记录时,一定要先把工单和会话的外键切过去,再删除旧档案。顺序反了会导致外键约束报错,甚至丢失历史记录。
4.3 消息乱序与重复:渠道Webhook重试的坑
有一个渠道的 Webhook 不稳定,偶尔会发生同一消息被推送两三次的情况。表现是客户在聊天窗口里发了一句“你好”,后台却收到三条“你好”。如果不做幂等,客户那边只发了一次,坐席却看到了三条,回复时也会三条一起回,场面非常尴尬。
我用的方案有两层。第一层是消息表增加唯一约束,用conversation_id + channel_message_id作为唯一索引;第二层是在入站接口里先查后写,命中已有消息就直接返回。双保险下来,重复投递基本能拦截干净。
乱序问题更难排查。有时候客户先发了一段长文字,又跟了一句“算了没事”,但后台先收到短句,后收到长文字。原因是 Webhook 推送不是严格按时间顺序到达的,可能因为网络延迟、队列积压导致后发先至。我的对策是:在入站消息上记录received_at和payload_timestamp两个时间,前端消息列表按received_at排序,但后台在处理“是否更新工单状态”这类动作时,会参考payload_timestamp先判断新旧,避免旧的延迟消息把新状态覆盖掉。
4.4 常见问题速查表
| 现象 | 大概率原因 | 处理办法 |
|---|---|---|
| 同一消息出现多次 | 渠道 Webhook 重复投递 | 消息表加 conversation_id + channel_message_id 唯一索引 |
| 客户重复建档 | 多渠道身份信息未汇聚 | 增加疑似重复列表,坐席人工合并 |
| 回复了消息但工单不更新 | 更新工单与发送回复非同一事务 | 加补偿任务,按顺序执行并支持重试 |
| 坐席收不到新消息提醒 | WebSocket 连接被服务端断开 | 增加心跳检测和自动重连,断线时改为轮询兜底 |
| 客户想改信息,但历史记录全变了 | 直接更新了客户主表 | 客户资料更新走快照表,保留每次修改记录 |
5. 后续还能怎么扩展
DeskcommCRM 跑完第一版之后,我明显感觉到,当所有沟通数据都汇聚到一处时,能做的事情比想象中多。
第一个可以扩展的方向是智能分诊。现在坐席分配还比较粗糙,只是按负载轮流,没有考虑“这个客户的问题属于哪个类型”。后续可以把历史工单的主题和内容做一个分类模型,在工单创建时自动打上“退货”“价格咨询”“技术支持”这类标签,再把这些标签作为分配算权重的一部分,让最擅长对应领域的坐席优先承接。这不需要一开始就做得非常重,即使只用简单的关键词匹配也能比纯轮询好很多。
第二个方向是客户满意度回访。系统里已经记录了每一次会话的完整结束时间,每次工单关闭后自动触发一条回访请求,让客户对本次服务打分。这个功能对客服团队管理非常有价值,而且因为数据都在自己库里面,统计口径完全可控,也不受第三方平台限制。
第三个方向是数据导出与报表。目前已经做了基础的会话量、响应时长、解决时长的统计,但真正能提升管理水平的是“坐席个人维度”的分析:平均首响时间是多少、单日处理量多少、哪些客户经常复购出现售后问题。把这块做成一个简单的报表页面,每周自动生成一次,管理者就能很直观地看到团队状态和客户健康度变化。
我在实际使用中的体会是,这类系统最有价值的不是那一堆表结构,而是“把所有客户触点收拢到一处”这个流程改造的本身。工具只是载体,真正让客服效率提升的,是大家第一次能在同一个界面上看到客户完整的前因后果。DeskcommCRM 还有很多粗糙的地方,但至少它解决了团队最痛的几个问题:消息分散、客户重复、交接断档。如果你也在规划类似的项目,我的建议是不要一上来就追求功能大而全,先把“客户识别—会话创建—坐席分配—工单闭环”这四段链路跑通,再逐步加智能化能力。这套核心链路稳了,后面往上叠加什么功能都不慌。