DeskcommCRM这个名字拆开看就是Desk Communication CRM直白点说就是把桌面客服沟通和客户关系管理做进同一个工作台。我最初做这个东西是因为团队内部用过几套成熟的客户管理系统功能倒是齐全但客服工作台和客户信息完全是两套独立场景微信群里的咨询、官网表单、历史工单散落在不同后台客户的基本资料、历史记录又要另外开一个页面去查。每天早上处理消息的时候光是在几个系统之间来回切换就要耗掉不少精力更别说追一个客户的完整脉络了。所以我一狠心自己动手做了一个轻量级的DeskcommCRM把聊天收件箱、客户档案、跟进记录、统计看板全部收敛到同一个界面里。这篇文章就把这个项目的设计思路、核心实现和踩坑记录完整拆开聊一遍适合正在做客服系统、工单系统或者想自己撸一个轻量CRM的开发者参考。1. 项目缘起为什么客户系统里总缺一块“工作台”1.1 团队真实痛点和需求梳理当时我们团队的处理流程大概是这样的客户在官网提交表单表单进了A系统销售用个人微信添加客户聊方案聊天记录留在手机上售后通过邮件沟通问题邮件又沉淀在另一个邮箱工具里。到了要复盘某个客户为什么流失的时候只能去各个后台查聊天记录、查邮件、查备注最后可能还得打几个电话才能拼出完整画面。这种割裂感在只有几个人的时候勉强能忍一旦每天的咨询量超过某个临界点就会变成纯体力活——不是在翻记录就是在去找记录的路上。所以一开始我就给自己定了三个必须满足的需求第一所有渠道的沟通消息要能聚合到同一个收件箱里不管客户是通过哪个口子进来的都能在同一个列表里看到第二客户的完整档案要能跟着会话一起出现打开一个会话就能看到这个人之前所有往来记录第三整个应用要轻不能为了一个工单系统再专门维护一套复杂的服务端基础设施。说白了我要的是一个“开箱就用、数据不散”的小工具而不是一个需要专门团队运维的重型平台。1.2 方案取舍为什么不直接买现成 CRM后来我也认真评估过直接采购成熟CRM的路线甚至试用过几个主流产品。说实话这些系统的功能确实完整销售漏斗、客户细分、自动化流程、报表中心全都有但对小团队来说有个很现实的困境一是贵按坐席收费人一多费用就上来二是重很多功能我们根本用不到但因为数据和流程都被限定在平台内部反而把现有的工作流搅乱了三是数据迁移和扩展很麻烦想接入自己内部的工单接口审批和适配成本非常高。自己造轮子的最大好处不是“省钱”而是“可控”。我可以用自己最习惯的方式组织数据模型把团队的客户沟通场景完全映射到系统里。更关键的是后续想加什么能力就加什么能力例如对接内部工单系统、生成周报数据、给不同渠道配置不同自动回复都在一个代码库里解决不用看供应商的脸色。1.3 技术路线确认Go SQLite React Electron技术选型上我最终采用的是 Go 后端 SQLite 数据库 React 前端 Electron 桌面壳的组合。当时也纠结过用 Python FastAPI 还是 Node.js但考虑到团队后续可能做单二进制部署Go 的编译产物直接扔到 Linux 服务器上就能跑非常省事就定了 Go。数据库没有上 PostgreSQL选了 SQLite。这个决定看起来有点“反常识”但仔细想是有道理的团队规模不大并发写入量有限SQLite 单文件备份方便WAL 模式下读写并发表现也足够而 PostgreSQL 虽然更强大但引入容器、备份、权限管理等额外心智负担对自用小工具来说是过度设计。前端我用了 React TypeScript界面实打实需要处理大量列表交互和状态同步类型约束能帮我少踩很多低级错误。桌面壳选了 Electron 而不是 Tauri一部分是因为当时团队里对 Node 生态更熟另一部分是 Electron 的成熟度更高踩坑成本更可控。2. 数据模型与核心模块设计2.1 实体关系客户、会话、消息怎么揉在一起整个系统最底层的东西其实是数据模型。如果模型设计得不好后面所有功能都像是盖在沙子上的房子。我的核心模型分为四张主表和几张辅助表。customers表存客户静态信息比如姓名、公司、邮箱、来源渠道以及一个 JSON 类型的custom_fields字段用于扩展个性化属性conversations表存会话每个会话归属于一个客户同时记录渠道类型、当前状态、负责人messages表存会话下的每一条消息区分方向inbound/outbound、消息类型文本、图片、链接、来源系统自动、坐席、客户notes表存跟进备注这种备注是内部的不发给客户。辅助表主要是tags、users、activity_logs。标签设计成多对多关系方便之后做筛选activity_logs记录谁在什么时间改了什么字段排查问题时能看出责任链。-- 核心表结构示例简写 CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE, name TEXT, company TEXT, source TEXT DEFAULT web, custom_fields JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE conversations ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL REFERENCES customers(id), channel TEXT NOT NULL, -- web / email / wechat / api status TEXT DEFAULT open, -- open / pending / closed assignee_id INTEGER REFERENCES users(id), first_seen DATETIME, last_reply DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id INTEGER NOT NULL REFERENCES conversations(id), direction TEXT NOT NULL, -- inbound / outbound content_type TEXT DEFAULT text, content TEXT, sender_type TEXT DEFAULT customer, -- customer / agent / system external_id TEXT UNIQUE, -- 渠道侧消息ID去重关键 created_at DATETIME DEFAULT CURRENT_TIMESTAMP );这套模型的关键点在于“会话”作为中间层。客户和消息之间不直接关联所有消息都挂在会话下面这样同一个客户的不同诉求可以被拆成多个独立会话分别跟进界面展示时也更有条理。2.2 状态机设计会话生命周期不靠人肉记忆会话状态是客服系统的核心之一。如果只用“打开/关闭”两个状态很多情况会糊弄过去客户回复了但没人接怎么办这个问题暂时挂起等客户回复怎么办转给其他同事后算谁的责任我的会话状态设计为open待处理、pending等待客户回复、assigned已分配但未处理、closed已完结。同时记录assignee_id和几个时间字段。这样设计的好处是待办列表可以直接筛选status open的会话而pending状态的会话可以延迟呈现在“等待回复”视图如果一个会话长时间停留在open但没有被分配后台能自动提示超时未处理。状态转移通过 API 统一控制不在前端直接改库。比如客户发来新消息时如果会话状态是closed系统会自动重新打开如果把一个assigned会话转给别人原来的负责人会收到动作日志职责转移有据可查。2.3 API 边界前端只关心业务动作在 API 设计上我刻意没有把数据库字段直接暴露给前端。不要让前端直接对conversations表做 UPDATE而是让前端调用POST /api/conversations/:id/assign或POST /api/conversations/:id/status这样的动作型接口。后端负责校验状态转移是否合法同时写入操作日志。之所以这么设计是因为动作型接口能避免状态转换逻辑散落前端。比如“关闭一个会话”这个动作前端只要发送{status: closed}后端会去判断当前状态是否允许关闭、是否需要更新客户的last_reply时间、是否要记录日志、是否要触发后续自动化规则。如果让前端直接改库这些规则迟早会被绕过数据一致性就崩了。2.4 数据一致性external_id 去重与幂等做消息系统最烦的一个问题是消息重复。Webhook 常常因为网络抖动重试IMAP 轮询也可能在某个时点重复拉取邮件。如果不去重客户会在界面里看到同一条消息出现两次严重时会导致客户收到重复回复。我的解决办法是给每条消息增加一个external_id字段并设置唯一索引。这个 ID 由渠道侧提供比如微信消息的 msgId、邮件的 Message-ID、Webhook 里的 event_id。写入消息前先尝试插入如果因为唯一索引冲突失败就直接跳过整个操作在事务里完成。这样即使上游重试十次数据库里也只会有一条记录。3. 关键功能落地收件箱、客户画像、统计看板3.1 统一收件箱渠道聚合与增量同步统一收件箱的难点不是“列出消息”而是“多个渠道同步进来时如何保持有序”。我的方案是每个渠道对应一个独立的sync_worker它负责从该渠道拉取新消息然后写入messages表并更新对应conversations表的last_reply和状态。渠道同步策略有所区别。基于 Webhook 的渠道是推送模式收到事件后写入即可基于 IMAP 的邮件渠道是轮询模式每两分钟拉一次最近的信件用updated_at last_sync_at做增量同步微信生态则通过服务端回调接口转发同样走 Webhook 链路。// Go 伪代码增量同步邮件的简化逻辑 func SyncMailboxSince(client *imap.Client, since time.Time) ([]MessageFromMail, error) { // 1. 搜索自 since 以来的 UID // 2. 批量抓取邮件头与正文 // 3. 将 external_id 设为 Message-ID // 4. 调用 SaveMessage(ctx, ...) 完成去重写入 }增量同步里最容易踩的坑是“漏拉”所以每次同步的起始位置要细致处理。我建议把last_sync_at存在独立的sync_state表里每次拉取完成后更新并且给整批拉取加上事务边界避免拉了一半崩溃后状态错乱。3.2 客户360°画像一次点击能看到全部脉络客户画像页面是把静态信息和动态行为拼起来。静态信息在customers表里包括公司、职位、自定义字段动态行为来自三类数据历史会话、历史备注、系统操作日志。实现上没什么高深技术但有一个交互细节很关键所有信息要在一个页面内分层呈现不要让客服为了查一个客户跳四五个路由。我的布局是左侧客户基本信息中间是会话历史列表底部是操作时间线右上角是标签编辑和归属人。这样打开客户页面时所有脉络扫一眼就清楚了。时间线数据用activity_logs表拼装包括“创建了会话”“发送了一条消息”“变更了状态从 open 到 pending”“添加了备注”。SQL 层把这些事件按时间倒序聚合前端渲染的时候按“天”分组视觉上就像看一个迷你版的审计流。3.3 自动化小规则关键词打标和自动分配自动化规则是我在开发后期加的因为发现客服团队每天大量重复动作是“看消息→判断类型→贴标签→分配给对应人”。既然动作是重复的就应该交给规则。规则引擎做得很克制只支持三类条件消息内容关键词匹配、客户来源渠道匹配、客户自定义字段匹配。动作支持打标签、设置会话优先级、自动分配给指定负责人、回复一段预设文案。规则用 JSON 配置存在rules表里后台解析执行。{ name: 退款关键词分配, when: { type: keyword, match: [退款, 退货, refund] }, then: [ {action: add_tag, tag: 售后}, {action: assign_user, user_id: 2}, {action: predefined_reply, message_id: 3} ] }这个设计最大的好处是可以随时加新规则不用改代码。同时我也加了规则命中日志方便之后分析自动化覆盖了多少消息防止规则互斥导致消息被重复处理。3.4 统计看板先保证指标口径一致统计看板这种功能难点从来不在 SQL 的写法而在指标口径。例如“首次响应时间”到底是客户发消息到坐席回复之间的时长还是客户发消息到消息被任何坐席打开之间的时长“待处理会话数”是只看状态为open还是包含超时未回复的pending我把指标口径全部收敛在同一个 SQL 查询模块里而不是在前端各自计算。比如每日会话量、平均首次响应时间、平均解决时长、渠道分布、消息量趋势这些指标统一由后端接口返回前端只负责渲染。这样即使后续要调口径也只需要改一处不会出现报表之间数字打架的尴尬。-- 平均首次响应时间计算示例 -- 口径客户首次 inbound 消息 - 该会话第一条 outbound 消息 的时长 SELECT c.id, (SELECT MIN(m2.created_at) FROM messages m2 WHERE m2.conversation_id c.id AND m2.direction outbound ) - (SELECT MIN(m1.created_at) FROM messages m1 WHERE m1.conversation_id c.id AND m1.direction inbound ) AS first_response_time FROM conversations c WHERE c.created_at date(now, -7 days);这个口径可能不是绝对标准但重要的是团队内部统一认可这个定义。每次跟运营核对数字前先把口径文档贴在数据看板底部能少很多无谓的争论。4. 桌面端与部署的工程细节4.1 为什么套桌面壳而不是纯 Web坦白说这个系统本来就是按 Web 应用设计的直接浏览器访问完全可行。那为什么还要加一层 Electron 桌面壳核心原因是使用习惯客服岗位的人一天到晚要在多个应用之间切换浏览器标签页开太多时消息提醒容易被淹没而且误关闭标签页的后果很严重。桌面壳的优势在于独立窗口、任务栏通知、本地缓存数据以及可以直接访问系统的通知机制。整个壳层我非常克制没有引入复杂的托盘菜单没有自动更新系统只是把前端打包成静态文件嵌入 Electron 资源目录后端单独运行在 localhost 的随机端口上由主进程拉起子进程并管理生命周期。4.2 升级、缓存与本地数据的取舍升级策略也设计得很务实后端每次启动时检查服务端版本如果服务端有新版本就下载二进制包到临时目录校验 SHA256 哈希之后替换自身再重启。前端资源打包在后端二进制里不需要单独部署。本地缓存我只缓存两类数据客户列表的粗粒度信息和会话索引目的是让应用启动后能秒开列表页详细内容再通过 API 拉取。缓存刷新策略很简单——每五分钟全量失效一次而且任何手动刷新操作都会绕过缓存直接请求最新数据。4.3 资源占用优化Electron 被诟病最多的是内存占用我确实踩过坑。最初每个会话详情都开一个独立 WebContents 去加载结果会话开多了内存直接爆掉。后来统一改成路由容器左侧是列表页的常驻 WebContents右侧详情区域用同一个容器内的 React 路由切换配合同一个浏览上下文内存直接少了一半以上。另外如果消息列表在后台更新时不要立即把几千条消息全部渲染在 DOM 里要虚拟滚动。我用的是 react-window 配合扁平消息数组实测几千条消息的会话也能流畅滚动滚动条表现和原生 IM 工具差不多。5. 常见问题与排查实录5.1 消息又双叒拉重复了问题表现某个渠道的消息在收件箱里出现两条一模一样的。查下来的原因通常不是代码逻辑问题而是老系统的 Webhook 没有签名校验上游因为超时重试了两三次每次携带的event_id相同但是我们的接收端点没有做去重。解决方案就是在消息写入前加唯一索引同时接收端点要做幂等检查。我在消息写入函数里统一封装了OnConflictDoNothing的处理数据库层面拦截一切重复写入。另外IMAP 渠道要特别留意某些邮件服务商返回的 Message-ID 可能是空的这时候需要自己生成一个 hash比如取邮件主题加接收时间的组合。5.2 中文搜索为什么慢问题表现搜索客户姓名时明明数据库里只有几万条记录但 LIKE 查询就是慢得让人难受。SQLite 默认的 LIKE 对中文没有分词能力本质上就是全表扫描加字符串匹配数据量上来后必然慢。我的解决方法是启用 SQLite FTS5 虚拟表对客户姓名、公司、邮箱建全文索引同时使用unicode61tokenizer。这里有个坑unicode61对中文的切分是整段连续文本没有词边界所以中文搜索要用substring match或者改用 trigram tokenizer。实测下来 trigram 对中文片段匹配非常友好适合“搜关键词就能命中”的场景。5.3 SQLite busy 和写锁问题表现多个 sync_worker 同时写入时偶尔报database is locked。这其实是 SQLite 的写锁机制多个连接同时写同一库会互相阻塞。解决方法是开启 WAL 模式并设置busy_timeout。PRAGMA journal_mode WAL; PRAGMA busy_timeout 5000;同时把写入操作尽量收敛到一个 writer 通道比如通过 Go channel 把所有写请求串行化。这个修改之后整个数据库层几乎没再出现过锁冲突。5.4 首次响应时间统计口径问题问题表现看板里的首次响应时间数字比直觉高很多。查下来发现是因为系统把自动回复也算成了“outbound 消息”导致客户发出第一条消息后自动回复被当作坐席响应时长被算成几毫秒。这显然不是我们想要的。修正方法是给消息增加sender_type字段严格区分agent和system首次响应时间只统计sender_type agent的 outbound 消息。这个改动虽然很小但指标立刻变得有意义了。建议所有统计口径相关规则都由后端统一控制避免前端各自实现导致的偏差。6. 一些工程以外的体会6.1 客服工作台的信息密度一屏内完成动作在使用过程中我最大的体会是客服处理消息时最怕“来回跳页面”。所以界面里我做了很多小优化会话列表直接显示客户最后一条消息预览和状态标签点击后右侧直接打开会话详情下方是客户小档案卡片不用跳到独立客户页就能完成备注、改标签、转交操作。只有需要看全历史的时候才展开完整客户画像页。这些看起来跟技术关系不大但实际使用时效率差距非常大。快捷键交互也值得做C关闭会话S保存备注R快速回复模板列表P标记为优先。每天处理几十个会话的客服这类快捷键能让手不离键盘。6.2 后续可能扩展的方向目前这个项目已经稳定跑了一阵子数据模型和核心功能我比较满意但肯定还有不足。我的计划是下一步把客户分群和统计报表做得更细比如按来源渠道和标签组合筛选客户看看哪个渠道进来的客户后续活跃度更高。另外一个方向是增加与内部工单系统的接口让客服直接在工作台里为复杂问题创建工单把售后链路也打通。如果你也在做类似的自用型工具我建议别急着把所有功能一次性装进去先把“收消息-看客户-回消息-记备注”这条主线走通后面再慢慢加花活。这个项目带给我最大的教训就是工具要贴近实际流程数据要能追根溯源其他都是后话。
