
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 workerRedis 任务队列没有做精确的一次性消费导致同一封邮件进来后两个 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反向代理、webFastAPI 后端、web-frontendVue 打包后的静态文件、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_code403, detail权限不足) return user return checker这个方案的优点是轻量、直观缺点是角色多了之后维护成本会上来。我们目前三到五个角色内完全够用等以后真要做细粒度的字段级权限控制再考虑换框架。6. 项目落地体会与后续扩展6.1 我在这个项目中学到的三件事第一数据模型的设计最值得花时间。DeskcommCRM 的代码写起来总共没多少但我有将近三分之一的时间都在反复推敲客户、交互记录、工单这三张表的关系。现在回过头看最难的其实不是写代码而是把客户的“唯一身份”和“跨渠道行为”抽象清楚。一旦抽象错了后面所有报表和工单流转都会被带偏。第二消息系统的最终一致性比实时性更重要。初期我过于追求 WebSocket 推送“零延迟”把大量逻辑放在推送链路上结果网络稍微抖动就出现消息丢失。后来调整为“以数据库落库为准推送做即时加速断线后自动拉取补偿”系统稳定性提升了一个档次。实时性和一致性要平衡不是越实时越好。第三团队内部工具也要重视用户体验。虽然用户只有十几个客服但界面是否顺手直接影响使用意愿。我把客服代表拉进来做了三轮使用评测根据反馈改了几十个细节比如“工单列表默认显示今天创建的”“客户详情页电话号码可以一键拨号”“消息输入框支持快捷键发送”。这些改动虽然不大但对提效很有帮助。6.2 后续可能的扩展方向DeskcommCRM 第一版其实已经满足了我们 90% 的核心需求但使用过程中我也发现了几个值得扩展的方向。一是自动化工单分配。目前新工单默认进入公共池需要客服手动认领。如果后续消息量继续增加可以做一个基于负载和技能标签的自动分配引擎比如轮询分配、按客户来源分配或者按客服当前待处理工单数分配。二是客户满意度评分。在工单关闭后给客户推送一个“请对本次服务评分”的链接反馈直接回填到客户和工单记录里这样管理者能直观看到服务质量的趋势。实现成本很低效果却很强。三是知识库推荐。客服在处理常见问题时经常需要翻手册如果在客服输入关键词时自动匹配知识库里的相关文档能减少大量来回查找的时间。这已经有点 AI 助手的味道值得尝试。DeskcommCRM 这个项目的价值在我看不仅仅是上线了一个内部系统更重要的是让团队形成了一种“以客户交互为中心”的做事思路。每次讨论新需求时大家第一反应都是“先看看数据模型里能不能表达”而不是“先做界面”。这种思路上的磨炼比技术选型带来的提升更持久。如果你也在规划类似系统我的建议是先想清楚客户怎么定义、交互怎么沉淀、工单怎么流转再动手写代码。想清楚了后面都是水到渠成的事情。