DeskcommCRM 这个名字最早出现在我们内部讨论的时候,其实是想表达两个东西:Desk 代表客服人员每天面对的桌面工作台,Comm 代表客户沟通,合在一起就是一套把沟通和工单处理整合在一起的客户关系管理系统。我在这个项目上断断续续做了接近八个月,从最初的需求梳理到后来上线给十几个客服同时使用,踩了不少坑,也积累了不少心得。这篇文章就围绕 DeskcommCRM 的整体设计、核心模块、落地过程、问题排查和后续扩展写一写,希望能给正在做同类系统的团队一点参考。
如果你正打算自建一套企业内部使用的 CRM,或者需要把工单、客户资料、多渠道消息(邮件、在线聊天、电话记录)合并到一个工作台里,那这篇文章基本能覆盖你从 0 到 1 会遇到的大部分问题。系统本身不大,但麻雀虽小五脏俱全,里面涉及的消息处理、工单状态机、权限控制、实时通知这些点,放到任何一套生产级系统里都是通用的。
1. 项目整体设计与思路拆解
1.1 我们为什么不用现成的 CRM 而要自己写
先说说背景。团队当时用的是一套开源工单系统加几个散落的沟通工具:客户邮件进工单,在线聊天走另一个系统,电话记录靠人手工填表。客服每天要在三个界面之间来回切换,经常出现邮件里聊了半天、工单里没更新、客户又在电话里问了同样问题的情况。更头疼的是,客户的历史信息分散在各处,新同事接手后根本不知道之前发生过什么。
市面上成熟的 CRM 我也认真评估过,Salesforce 这种太重,实施成本高,而且我们内部对客户字段、工单流转规则有很强的定制需求;一些轻量级 SaaS 虽然开箱即用,但数据全在云端,公司有明确的数据本地化要求。对比了一圈之后,团队决定用三个月的业余时间自研一套轻量级的 DeskcommCRM,核心目标就一句话:让客服在一个界面里完成所有客户沟通和工单处理。
这个决策听起来有点“重复造轮子”,但实际上对我们是合理的。因为自研可以把“桌面通信”这个场景做到极致——比如客户来电时自动弹出历史工单、同一位客户在不同渠道的消息自动聚合、工单状态变化时桌面端实时弹通知。这些都是通用软件很难做深做透的。
1.2 三个核心设计原则
在动手写第一行代码之前,我们定了三条设计原则,后面所有方案选型都围绕这三条走:
第一,消息先统一,工单再流转。不管客户从哪个渠道进来,邮件、表单、在线聊天还是电话录音转写文本,最终都要归一成一条标准化的“交互记录”。工单可以基于交互记录来创建和流转,这样就能保证客服看到的是完整上下文,而不是碎片化的消息列表。
第二,状态机驱动工单生命周期。工单的状态不能靠客服随便改,而是由操作触发状态转移。比如“待处理”只能通过“认领”操作变成“处理中”,“处理中”只能通过“标记解决”变成“已解决”。这样既能避免状态混乱,也能在状态变化时准确地触发通知、计时等动作。
第三,实时性优先于一切。客服工位上的体验核心就是“客户消息进来马上能看到”。所以消息总线层我们选了 WebSocket 而不是轮询,通知中心做了声音加桌面弹窗的双重提醒,实测下来从客户点击发送到客服桌面弹出通知,延迟能控制在 500 毫秒以内。
1.3 核心需求解析
把需求归纳成一张表会特别清楚:
| 模块 | 需求描述 | 验收指标 |
|---|---|---|
| 客户管理 | 建立统一的客户档案,记录联系方式、历史购买记录和所有交互历史 | 同一个客户多渠道资料自动合并 |
| 工单系统 | 支持创建、转派、认领、解决、关闭全流程,支持自定义状态 | 状态只能按规则流转 |
| 消息接入 | 接入邮件、在线聊天、电话记录三类渠道 | 新消息 1 秒内推送至桌面 |
| 桌面通知 | 新消息到达时通知栏提示,支持声音和未读角标 | 不丢提醒、不重复提醒 |
| 报表 | 统计客服处理量、响应时长、工单解决率 | 关键指标实时刷新 |
这三块相互独立又彼此依赖,最开始我们想一步到位全做完,后来还是决定拆成三个迭代周期:先做客户管理和工单,再做消息接入,最后做通知中心和报表。每一个迭代都能独立使用,这对项目管理来说也友好很多。
2. 核心细节解析与实操要点
2.1 技术栈选型与理由
技术选型这部分我们没怎么纠结,基本都是团队已经熟悉的方案,但有几个地方是认真权衡过的。
后端选了 Python + FastAPI。原因很简单:异步支持好,写接口快,团队里人人会调。使用 FastAPI 的 WebSocket 来做实时消息推送,比 Flask 加第三方库省心很多。数据库用了 PostgreSQL,既是关系型数据的主存储,也用来存消息记录;虽然消息量大会有压力,但配合分区表和 Redis 缓存,扛住我们每天几千条消息完全没问题。Redis 在这里承担了两个职责:一是把未读通知计数放在内存里,便于快速读写;二是做简单的任务队列,比如发送邮件通知、生成报表这些后台任务丢给 Redis 队列去处理。
桌面端我们做了一个很轻的 Web 应用,直接用 Vue 3 + Element Plus,打包后放在内网服务器上,客服用浏览器登录即可,这比做 Electron 桌面客户端要省事得多。当时有人提议用 Electron 做原生桌面端,理由是可以调用系统通知,但实际上浏览器的 Notification API 已经完全够用,还省去了分发客户端的麻烦。
2.2 数据模型设计:客户、交互、工单三张核心表
数据模型是整个系统的地基,我在这个阶段花的时间比写代码还多。最终核心表就三张,所有业务都围绕它们展开。
客户表(customers):存储客户基础信息。这里有个容易踩坑的点,就是“同一个客户”的定义。我们刚开始用邮箱作为唯一标识,结果发现同一个客户可能用不同邮箱发邮件,后来改为把手机号、邮箱、客户编号三个字段都做归一化映射,每次新消息进来先用匹配规则查客户,匹配不到再新建。
交互记录表(interactions):这是 DeskcommCRM 的中心表。无论客户通过什么渠道联系,都会往这张表里插一条记录,字段包括渠道类型、方向(客户发起还是客服回复)、正文内容、关联客户 ID、关联工单 ID、原始元数据(比如邮件头、聊天会话 ID)。我们甚至把电话录音转写出的文本也塞进来,这样客服在系统里能看到完整的客户沟通轨迹。
工单表(tickets):保存工单编号、标题、描述、当前状态、优先级、指派人、关联客户 ID、创建时间、解决时间等。工单状态变化都通过一个独立的ticket_events表记录下来,方便后续回溯。
这三张表的关系就是一个三角形:客户关联多个交互记录和多个工单,交互记录可以关联到某个工单。报表查询基本都是围绕这三个维度做聚合。
2.3 工单状态机的实现细节
状态机用了最经典的“状态加动作”模型。我把工单定义成这几个状态:待处理、处理中、待客户反馈、已解决、已关闭。每个状态之间允许的动作如下:
- 待处理 -> 认领 -> 处理中
- 处理中 -> 请求补充信息 -> 待客户反馈
- 待客户反馈 -> 收到客户回复 -> 处理中
- 处理中 -> 标记解决 -> 已解决
- 已解决 -> 关闭 -> 已关闭
- 已关闭 -> 重新打开 -> 待处理
代码层面我没有引入太复杂的工作流引擎,直接用一个 Python 字典来做状态转移表:
TRANSITIONS = { "待处理": {"claim": "处理中"}, "处理中": {"request_info": "待客户反馈", "resolve": "已解决"}, "待客户反馈": {"receive_reply": "处理中"}, "已解决": {"close": "已关闭"}, "已关闭": {"reopen": "待处理"}, }每次状态变更时先查这个表,如果动作不合法就抛异常。这样做有两点好处:一是代码简单,新同事看一眼就懂;二是后续想加“自动关闭超时工单”之类的规则,只需要扩展这个表再加上一个定时任务就行。工单状态的每一次变化都会写入ticket_events表,这一点非常重要,因为复盘客服处理效率时全靠这些事件数据。
3. 实操过程与核心环节实现
3.1 多渠道消息接入的标准化处理
消息接入是 DeskcommCRM 里最体现“Comm”这个词的部分。我把它拆成了三类:邮件、在线聊天、电话文本记录。每一类都有单独的入口,但处理流程是统一的:原始消息进来 -> 解析 -> 清洗 -> 入库 -> 触发通知。
邮件这块我用的是 IMAP 轮询加解析。每个客服邮箱是一个共享收件箱,系统每 30 秒拉取一次未读邮件,解析发件人、主题、正文。这里有个技巧:邮件的 HTML 正文里有很多样式和签名,直接入库会显得很乱,必须用beautifulsoup4提取主要文本,并且把引用历史(>开头的行)过滤掉。我实测下来这个步骤能减少 90% 的垃圾内容。
在线聊天则走了一个嵌入到客户页面的 JavaScript SDK,其实就是 WebSocket 连接。客户发一句,消息直接通过后端接口进入交互记录表,再通过 WebSocket 广播给对应客服的桌面端。
电话记录相对简单,因为我们的电话系统能把通话记录存成 CSV 文件,经过一个脚本自动导入到交互记录表,转录文本放在raw_metadata里。导入脚本还要负责把通话时长、通话方向这些元数据解析出来,方便后续做统计。
统一处理后,我写了一个标准化的消息数据结构:
{ "channel": "email", "direction": "inbound", "customer_id": 12345, "ticket_id": 678, "content": "客户正文内容...", "raw_metadata": { "message_id": "xxx", "sent_at": "2024-03-20T10:00:00" } }所有渠道的消息都转成这个结构,后续处理就变得非常统一。
3.2 实时通知链路的完整打通
实时通知链路是用户感知最强的部分,也是当时调试时间最长的地方。整体链路是:
- 消息到达后端路由,先写入 PostgreSQL 交互记录表。
- 写入成功后,Redis 里对对应的客服用户未读数加一。
- 同时后端通过 WebSocket 向该客服的在线连接推送一条“新消息”事件。
- 浏览器端收到事件后,刷新消息列表和工单详情,弹出系统通知并播放提示音。
这个链路里最容易出问题的就是第三步“推送”。如果一个客服开了两个浏览器标签页,WebSocket 连接就有两个,消息会重复推送两次。我在后端维护了一个user_connections的字典,一个用户对应多个 WebSocket 连接,推送时遍历所有连接发送,但前端收到事件后会把消息先从本地去重(根据消息 ID),再用 Notification API 弹一次通知。这样既保证了任何标签页都能看到,又不会重复提醒。
前端监听消息的代码简化成大概这个样子:
socket.onmessage = (event) => { const payload = JSON.parse(event.data); if (payload.type === "new_message") { unreadCount += 1; renderMessageList(payload.data); showDesktopNotification(`新消息来自 ${payload.data.customer_name}`); } };这个通知要做成“仅当页面在后台时才弹窗”的效果,否则客服正在看着当前消息时突然弹通知反而打扰。我加了一个document.hidden的判断,只有标签页处于后台时才弹系统通知,前台时只更新列表。
3.3 客服工作台的关键交互细节
客服打开 DeskcommCRM 后,默认看到的是一个三栏布局:左侧是客户列表,中间是消息对话流,右侧是当前客户的资料和关联工单。这个布局是参考主流客服软件反复调整出来的,三栏布局最大的好处是“抬头可见”。
消息对话流我实现成了类似微信聊天的气泡样式,客户消息靠左,客服回复靠右。每条消息下面还会显示一个小标签,比如“邮件”“在线聊天”“电话转录”,这样客服一眼就能看出当前在跟客户用什么渠道沟通。有一个交互细节我们反复打磨了很久:当客服回复邮件时,消息会默认通过邮件网关发出;当客服回复在线聊天时,消息会直接通过 WebSocket 发给客户页面。这个“只对着对话框打字,渠道自动路由”的体验,节省了客服大量的复制粘贴时间。
右侧客户资料区还要展示一个“最近 5 个工单”的摘要,这个非常有用。我特意把工单摘要做成了可折叠卡片,点开就能看到之前的工单描述、解决状态还有当时的处理人,方便客服快速了解过往背景。
3.4 报表模块的实现思路
报表模块其实没有用特别复杂的技术,就是定时任务加 Redis 缓存加 ECharts 展示。每晚凌晨跑一次聚合任务,把前一天每个客服的工单处理量、平均响应时长、消息量统计出来,存入一张daily_agent_stats表。报表页面直接从这张表读取数据,查询很快,基本不需要优化。
这个方案的主要问题是实时性差,每天只看前一天的数据。后来我加了一个白天的实时刷新指标,顶部展示“今日新消息数”“待处理工单数”“平均响应时长”三个核心数字,每 30 秒从接口拉取一次,数据都是临时聚合 Redis 里的实时计数值。大多数管理者只需要看趋势,只有节假日前后才会盯实时数据,所以这个双轨方案完全够用。
4. 常见问题与排查技巧实录
4.1 消息重复入库
这个问题的典型场景是:客户在线聊天发送消息后点击了两次发送按钮,后端收到了两条几乎一样的消息;或者邮件轮询时邮件服务器返回了相同邮件两次。
排查思路:第一步先检查是不是前端按钮没有做防抖;第二步查后端是否在消息写入前做了唯一性校验。我们最终用了两个手段来防重复:
- 在线聊天的前端在按钮点击后立刻置灰 1 秒。
- 后端对每个渠道的
raw_metadata里的消息唯一标识(比如邮件带上message_id,在线聊天带client_message_id)做了一次 Redis SETNX 检查,如果已经处理过就直接忽略。
这个方案在实测中把重复入库率从“偶尔出现”降到了零。
4.2 WebSocket 掉线导致客服收不到消息
线上出过一次事故:有客服反馈一整个下午没有收到任何消息,但客户那边反馈已经发了好几条。排查过程挺曲折的,最终发现问题出在公司内网的代理服务器上,代理默认 5 分钟就断开了空闲的 WebSocket 连接,而我们的后端又没有做心跳检测,连接断开后前端没有任何感知。
解决方案是前后端都加心跳:后端每隔 30 秒发送一个ping帧,前端收到后回复pong,如果前端超过 90 秒没收到任何消息就主动重连 WebSocket,并拉取一次未读消息作为补偿。这个机制上线后,再没出现过“静默断线”的情况。
4.3 工单状态不流转
有一次测试发现,客服点击“标记解决”后工单状态没有任何变化,日志里也没有报错。最后定位到是状态机表的 key 有多余空格,数据库里存的是“已解决 ”,而代码里查的是“已解决”。虽然问题很小,但排查过程让我意识到状态机相关的代码一定要有清晰的可观测性。后来我加了一个ticket_events的审计日志,每次尝试状态变更时都记录动作和结果(成功或失败原因),再遇到这类问题只需要看日志就能秒定位。
4.4 通知中心重复提醒
这个问题的根因是消息被两个后端实例同时消费。我们早期部署了两个 Gunicorn worker,Redis 任务队列没有做精确的一次性消费,导致同一封邮件进来后两个 worker 同时处理,生成了两条交互记录和两次通知。后来处理方式是引入分布式锁,用 Redis 的SET NX EX对消息唯一 ID 加锁,谁拿到锁谁处理,另一个直接丢弃。持久层的唯一索引也加上,相当于双保险。
4.5 常见问题速查表
| 问题症状 | 可能原因 | 快速排查方法 | 解决方案 |
|---|---|---|---|
| 消息重复入库 | 前端重复提交 / 消费者重复消费 | 查看交互记录表是否有相同 message_id | 前端按钮防抖加后端唯一性校验 |
| 收不到实时消息 | WebSocket 被代理断开 | 浏览器控制台查看 WebSocket 状态 | 前后端增加心跳检测与自动重连 |
| 工单状态不流转 | 状态机 key 不匹配 / 非法动作 | 查看 ticket_events 审计日志 | 统一状态枚举,增加审计记录 |
| 通知重复弹出 | 多标签页 / 多消费者 | 查看连接数和服务端日志 | 前端消息去重,后端加分布式锁 |
| 邮件正文乱 | HTML 未清洗 | 查看原始邮件内容 | 用解析库提取主要文本,过滤签名 |
| 报表数据不准 | 定时任务失败 / 时区问题 | 查看定时任务日志 | 统一使用 UTC 存储,展示时转换时区 |
5. 部署运维与团队协作
5.1 Docker Compose 一键部署
为了让环境保持一致,整个系统用 Docker Compose 编排。容器一共五个:nginx(反向代理)、web(FastAPI 后端)、web-frontend(Vue 打包后的静态文件)、postgres、redis。开发环境一条docker compose up就能跑起来,生产环境加一个.env文件配置数据库密码和密钥。
部署文件核心部分大概是这样的:
services: db: image: postgres:15 environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - db_data:/var/lib/postgresql/data redis: image: redis:7-alpine web: build: ./backend depends_on: - db - redis environment: DATABASE_URL: postgresql://deskcomm:${DB_PASSWORD}@db:5432/deskcomm REDIS_URL: redis://redis:6379/0 frontend: build: ./frontend depends_on: - web nginx: image: nginx:alpine ports: - "80:80" volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf depends_on: - web - frontend这里有个坑要提醒大家:FastAPI 应用在容器里启动时,要用带--workers 2的 Gunicorn 或 Uvicorn,但 worker 数量不能太多,否则会跟 Redis 连接数打架。我们压测过,2 个 worker 加上数据库连接池设为 10,足够支撑 50 个客服同时在线,再多就要考虑加实例了。
5.2 监控告警的简单落地
监控这块我们没有上很重的东西,用的是 Prometheus 加 Grafana,主要盯四个指标:每分钟消息数、WebSocket 在线连接数、API 响应时间和数据库连接池使用率。告警规则也简单:消息数五分钟低于某个阈值且不是深夜,说明消息链路可能有问题;在线连接数超过预期,说明可能有重复连接或异常客户端;API 响应时间超过 2 秒就发告警到钉钉群。
有一次线上发现问题就是靠这个监控:凌晨报表任务把这个白天性能正常的应用拖慢了,API 响应时间飙到 4 秒多。我们查了半天发现是报表任务每天跑全量数据扫描,把数据库 IO 占满了。后来把报表查询改成只扫描当天的增量数据,并放到凌晨低峰期执行,问题就解决了。
5.3 权限控制的设计
客服团队有管理员、组长、普通客服三个角色。管理员能做所有操作,组长能查看本组所有工单和报表,普通客服只能看到自己认领的工单。权限控制没有引入第三方库,FastAPI 的依赖注入就能实现:
def require_role(role: str): def checker(request: Request): user = get_current_user(request) if user.role != role: raise HTTPException(status_code=403, detail="权限不足") return user return checker这个方案的优点是轻量、直观,缺点是角色多了之后维护成本会上来。我们目前三到五个角色内完全够用,等以后真要做细粒度的字段级权限控制再考虑换框架。
6. 项目落地体会与后续扩展
6.1 我在这个项目中学到的三件事
第一,数据模型的设计最值得花时间。DeskcommCRM 的代码写起来总共没多少,但我有将近三分之一的时间都在反复推敲客户、交互记录、工单这三张表的关系。现在回过头看,最难的其实不是写代码,而是把客户的“唯一身份”和“跨渠道行为”抽象清楚。一旦抽象错了,后面所有报表和工单流转都会被带偏。
第二,消息系统的最终一致性比实时性更重要。初期我过于追求 WebSocket 推送“零延迟”,把大量逻辑放在推送链路上,结果网络稍微抖动就出现消息丢失。后来调整为“以数据库落库为准,推送做即时加速,断线后自动拉取补偿”,系统稳定性提升了一个档次。实时性和一致性要平衡,不是越实时越好。
第三,团队内部工具也要重视用户体验。虽然用户只有十几个客服,但界面是否顺手直接影响使用意愿。我把客服代表拉进来做了三轮使用评测,根据反馈改了几十个细节,比如“工单列表默认显示今天创建的”“客户详情页电话号码可以一键拨号”“消息输入框支持快捷键发送”。这些改动虽然不大,但对提效很有帮助。
6.2 后续可能的扩展方向
DeskcommCRM 第一版其实已经满足了我们 90% 的核心需求,但使用过程中我也发现了几个值得扩展的方向。
一是自动化工单分配。目前新工单默认进入公共池,需要客服手动认领。如果后续消息量继续增加,可以做一个基于负载和技能标签的自动分配引擎,比如轮询分配、按客户来源分配或者按客服当前待处理工单数分配。
二是客户满意度评分。在工单关闭后给客户推送一个“请对本次服务评分”的链接,反馈直接回填到客户和工单记录里,这样管理者能直观看到服务质量的趋势。实现成本很低,效果却很强。
三是知识库推荐。客服在处理常见问题时经常需要翻手册,如果在客服输入关键词时自动匹配知识库里的相关文档,能减少大量来回查找的时间。这已经有点 AI 助手的味道,值得尝试。
DeskcommCRM 这个项目的价值,在我看不仅仅是上线了一个内部系统,更重要的是让团队形成了一种“以客户交互为中心”的做事思路。每次讨论新需求时,大家第一反应都是“先看看数据模型里能不能表达”,而不是“先做界面”。这种思路上的磨炼,比技术选型带来的提升更持久。
如果你也在规划类似系统,我的建议是:先想清楚客户怎么定义、交互怎么沉淀、工单怎么流转,再动手写代码。想清楚了,后面都是水到渠成的事情。