news 2026/9/17 8:18:25

从零构建轻量级CRM客服工作台:Go+SQLite+React+Electron实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零构建轻量级CRM客服工作台:Go+SQLite+React+Electron实战

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表存跟进备注,这种备注是内部的,不发给客户。

辅助表主要是tagsusersactivity_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/assignPOST /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字段,严格区分agentsystem,首次响应时间只统计sender_type = 'agent'的 outbound 消息。这个改动虽然很小,但指标立刻变得有意义了。建议所有统计口径相关规则都由后端统一控制,避免前端各自实现导致的偏差。

6. 一些工程以外的体会

6.1 客服工作台的信息密度:一屏内完成动作

在使用过程中,我最大的体会是客服处理消息时最怕“来回跳页面”。所以界面里我做了很多小优化:会话列表直接显示客户最后一条消息预览和状态标签,点击后右侧直接打开会话详情,下方是客户小档案卡片,不用跳到独立客户页就能完成备注、改标签、转交操作。只有需要看全历史的时候才展开完整客户画像页。

这些看起来跟技术关系不大,但实际使用时效率差距非常大。快捷键交互也值得做:C关闭会话,S保存备注,R快速回复模板列表,P标记为优先。每天处理几十个会话的客服,这类快捷键能让手不离键盘。

6.2 后续可能扩展的方向

目前这个项目已经稳定跑了一阵子,数据模型和核心功能我比较满意,但肯定还有不足。我的计划是下一步把客户分群和统计报表做得更细,比如按来源渠道和标签组合筛选客户,看看哪个渠道进来的客户后续活跃度更高。另外一个方向是增加与内部工单系统的接口,让客服直接在工作台里为复杂问题创建工单,把售后链路也打通。

如果你也在做类似的自用型工具,我建议别急着把所有功能一次性装进去,先把“收消息-看客户-回消息-记备注”这条主线走通,后面再慢慢加花活。这个项目带给我最大的教训就是:工具要贴近实际流程,数据要能追根溯源,其他都是后话。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 8:16:03

Ollama本地部署全攻略:从安装到前端接入的完整实践指南

别急着敲命令,先花两分钟想清楚一件事:你是真需要本地模型,还是只是因为跟风才想部署本地模型?这个判断做错了,后面所有步骤都会变成无用功。我见过太多人把 Ollama 装好、模型拉下来、前端页面调通,结果用…

作者头像 李华
网站建设 2026/9/17 8:15:43

Agent Skills 实战指南:从技能定义到编排评估的完整方法论

1. 先搞清楚:agent skills 不是"给 Agent 加个插件"1.1 从一次需求沟通说起上个月团队里来了个新同学,接手一个客服问答 Agent 的优化任务。他跑过来问我:"我要给这个 Agent 加一个查订单的技能,是不是直接接一个订…

作者头像 李华
网站建设 2026/9/17 8:13:23

Joplin实战生存指南:WebDAV+S3双轨同步与Evernote迁移避坑

1. 这不是一本“说明书”,而是一份Joplin实战生存指南如果你在搜索栏里敲下“Joplin 使用手册”,大概率会看到一堆零散的Wiki页面、GitHub上的Readme片段,或者几篇三年前写的、连截图都还是旧版UI的教程。它们要么太浅——告诉你“点这里新建…

作者头像 李华
网站建设 2026/9/17 8:13:22

Ubuntu 20.04 Samba 启动失败 status=255 排查修复

装完 Samba 敲下systemctl start smbd,终端里直接甩出一行Job for smbd.service failed because the control process exited with error code,再systemctl status smbd一看,末尾赫然写着status255/n/a——这个画面我在 Ubuntu 20.04 上见过太…

作者头像 李华
网站建设 2026/9/17 8:13:06

Sanity 仓库实战:playwright-cli 浏览器自动化命令行完全指南

Sanity 仓库实战:playwright-cli 浏览器自动化命令行完全指南 【免费下载链接】sanity Sanity Studio – Rapidly configure content workspaces powered by structured content 项目地址: https://gitcode.com/GitHub_Trending/sa/sanity 本篇技术指南以 .a…

作者头像 李华