1. 项目诞生背景:为什么放着现成CRM不用,偏要自己写一个
1.1 团队的痛点不是“没有CRM”,而是“没有合适的CRM”
我们这个团队做的是B2B项目型销售,客户数量不算夸张,大概几百个,但每个客户的生命周期特别长:从初次接触到最终签约往往要跨三四个月,中间涉及产品演示、报价、法务对接、合同审批、交付对接好几个环节。最早大家用共享表格管理客户,后面客户一多、参与的人一多,表格就开始失控了:同一个客户在不同的Sheet里有三个版本的联系方式,谁也不知道最新的报价是哪一版。
这时候想起了上CRM。但是市面上的SaaS CRM我们基本都试了一圈,最后都停在同一个问题上:数据不在自己手里。销售数据是我们最核心的资产,把客户联系人、报价记录、沟通历史全部放在别人的服务器上,过不了我们内部信息安全那一关。更麻烦的是,销售经常在外网环境或者客户现场,网络时好时坏,但SaaS CRM一旦离线就基本等于白板,连查个客户历史沟通记录都做不到。
1.2 现有工具的共性短板:重、贵、定制难
我梳理了一下我们试用过的CRM工具,问题集中在三块:
| 痛点类型 | 具体表现 | 对我们团队的影响 |
|---|---|---|
| 定制成本高 | 想加一个“项目阶段”字段,或者改一下销售漏斗的看板逻辑,往往需要买更贵的版本,甚至是提工单等排期 | 流程变通慢,销售等不起 |
| 离线能力弱 | 网页版依赖网络,桌面版也需要实时联网校验权限和拉取数据 | 出差、客户现场几乎不能用 |
| 通信数据割裂 | 邮件、电话记录、会议纪要都以“附件”形式存在,没有一个统一的视图 | 复盘客户关系时靠人脑回忆 |
一开始我们想过用那种开源CRM改一改,最常被提到的SuiteCRM、EspoCRM、Twenty都算了一圈。它们功能确实全,但是代码量很大,数据库结构也被对方定义死了。我们需要的不是一套完整的CRM系统,我们需要的是一个“销售工作台”:客户资料、沟通记录、待办任务、项目阶段,这四样东西做一个干净好用的桌面端工具,不搞复杂的权限体系,不搞自动化营销,不搞一堆没人用的报表。
1.3 定下边界:不是重构CRM,而是做一个“桌面通信 + 客户管理”的垂直工具
这里必须先说清楚一个判断。任何项目在启动前都要明确“你不做什么”,比“你做什么”还重要。我们的目标不是做一个又大又全的通用CRM去跟市面产品对标,而是做一个贴合自己销售流程的桌面工具。所以名字也很有意思:DeskcommCRM。Desk表示桌面端,Comm是Communication(通信)的缩写,组合在一起就是“带通信能力的桌面客户管理工具”。
我们给自己的边界定义是:
- 客户数据和跟进记录必须完全本地存储,同步只是一个备份和跨设备手段
- 通信信息(邮件、通话记录)能做到自动归集,但不做强实时聊天
- 不重复造轮子,日历、邮件收发、文件预览这些能力能用成熟组件就用成熟组件
- 部署形态以团队内网自托管为主,单机可用,不上云
这个边界定下来之后,整个项目的技术选型就好做多了。
2. DeskcommCRM的技术栈选型与整体架构
2.1 客户端框架:为什么锁定 Electron + Vue 3 + TypeScript
桌面端方案当时考虑过三个:Electron、Tauri、Qt。我们团队的技术背景是Web全栈,对前端生态最熟,Qt直接排除了,因为它意味着要引入一套新的C++技术栈,团队的学习成本太高。
Tauri是后来认真评估过的方案。它的安装包确实小,打包出来只有几MB,内存占用也比Electron低一个量级。但当时Tauri的生态还不够成熟,我们需要的几个原生能力——比如系统托盘集成、本地SQLite访问、开机自启动、自动更新——在Tauri里都需要折腾Rust插件,很多社区库还处于早期阶段。考虑到DeskcommCRM是给销售团队用的生产工具,稳定性优先,我们最终选了Electron。
技术栈的具体选择:
- Electron 28 + electron-builder:桌面壳和打包发布
- Vue 3 + TypeScript:界面层,Composition API组织业务逻辑
- Pinia:客户端状态管理
- better-sqlite3:本地数据库,同步API、性能好
- Fastify:本地内置HTTP服务,用于处理一些渲染进程不方便直接做的文件操作
- Tailwind CSS + Headless UI:界面样式,主打简单清晰
Electron的方案虽然“重”了一点,但是生态成熟,我们需要解决的问题基本都有现成答案。事实也证明这个决定节省了大量时间,后面遇到的所有兼容性问题,基本都能在社区找到类似案例。
2.2 服务端与存储:本地优先,内网同步
DeskcommCRM的数据存储采用“本地优先”架构。核心数据落在每台电脑的SQLite数据库里,然后通过一个轻量级同步服务,把数据增量同步到内网的PostgreSQL数据库中。销售出差时,没有网络也能正常查看和录入客户信息,等网络恢复后自动把本地变更推送到服务端。
之所以用PostgreSQL而不是MySQL,原因很简单:我们服务端有一个字段是JSONB类型,用来存客户资料的扩展属性。PostgreSQL的JSONB支持索引和高级查询,这在后期做自定义字段、客户分群时非常方便。MySQL虽然也有JSON类型,但灵活性和查询能力比JSONB差一些。
服务端是一个Node.js + Fastify的小服务,部署在内网一台4核8G的小主机上。它做的事不多:身份认证、数据同步接口、邮件转发(可选)、审计日志存储。不做复杂业务逻辑,核心业务逻辑全部在客户端。这样设计的好处是,即便服务端挂了,客户端依然可以正常使用,不会影响销售干活。
2.3 通信模块的接入方式:邮件归集是第一个硬骨头
DeskcommCRM里的“Comm”不是简单的联系人通信录,而是要自动把往来邮件、通话记录归集到对应的客户下,形成一条完整的互动时间线。
邮件这块我们用了标准协议IMAP和SMTP。客户端直接连接企业邮箱的IMAP服务器,拉取指定文件夹(比如“已发送”“销售收件箱”)的邮件,通过解析发件人域名和收件人地址,自动关联到本地客户记录上。后期我们加了POP3的支持,但IMAP是主流,因为IMAP支持服务端文件夹同步,客户端可以记住已拉取的邮件UID,避免重复导入。
电话记录这块比较朴素:从华为、思科等电话交换机系统导出通话记录CSV,DeskcommCRM提供一个导入向导,按被叫号码匹配客户联系人。如果匹配到就挂到该客户的时间线下面,没匹配到的进“未关联通话”池子,可以手动归类。
当时很多朋友问我为什么不做WebRTC直接集成网络电话。说实话,企业销售团队的核心通信还是邮件和手机,而且电话系统每家用的都不一样,直接做软电话的集成性价比太低。做得浅没意义,做得深又需要和不同运营商对接,不是一个小团队能做完的事。所以“导入 + 归集”是投入产出比最高的方案。
2.4 前端目录结构与模块划分
代码组织这块我花了一些心思。DeskcommCRM本质上是一个“重客户端”应用,业务逻辑大部分在本地,因此前端不能只按页面组织目录,必须按领域模块来划分。
src/ ├── main/ # Electron主进程 │ ├── db/ # SQLite初始化、数据访问层 │ ├── sync/ # 同步引擎 │ ├── mail/ # IMAP/SMTP封装 │ └── ipc/ # 主进程与渲染进程通信的IPC handler ├── renderer/ │ ├── components/ # 通用UI组件 │ ├── views/ # 页面级组件 │ ├── stores/ # Pinia状态 │ └── utils/ # 工具函数 ├── shared/ │ ├── types/ # 跨进程共享的TypeScript类型定义 │ └── constants/ # 枚举和常量 └── resources/ # 图标、安装包配置等静态资源这个目录结构最大的好处是“主进程代码”和“渲染进程代码”严格分离。Electron开发最常见的坑就是把所有逻辑都堆在renderer里,结果一旦涉及原生模块调用就得用IPC绕来绕去,代码混乱不堪。我们从一开始就把数据访问全部放在main进程,renderer只通过IPC调用,界面层完全不知道数据是怎么存的。
3. 核心功能模块的实现细节:不是堆功能,而是打通流程
3.1 客户资料管理的字段设计
客户资料是CRM的地基,字段设计直接决定后续功能的灵活程度。我们一开始没有像传统CRM那样设计几十个字段,而是遵循一个原则:核心字段固定化,业务字段扩展化。
固定字段用数据库表列实现:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TEXT | UUID主键 |
| name | TEXT | 客户名称 |
| industry | TEXT | 所属行业 |
| level | INTEGER | 客户等级:1-5 |
| owner_id | TEXT | 负责人ID |
| source | TEXT | 客户来源 |
| created_at | TEXT | 创建时间 |
| updated_at | TEXT | 更新时间 |
业务扩展字段存在一个叫custom_fields的JSONB列中。这样设计的好处是,不同行业的销售团队可以根据自己的业务特点配置字段,而不用改数据库结构。比如做软件服务的团队可能想知道“客户用的什么技术栈”,做制造业的可能关心“设备台数”,这些都可以通过界面配置加入自定义字段,而不需要写代码。
UI上我们采用了“分组折叠”的展示方式:基本信息、联系信息、业务信息三个分组。默认折叠业务信息,因为大多数情况下销售打开客户详情页是想快速找到联系人电话和最近跟进情况,而不是看一堆自定义字段的填表信息。
3.2 跟进记录“时间线”的数据模型
时间线是DeskcommCRM中最常用、也是我最有心得的一个模块。它本质上就是一个按时间排序的流,把不同类型的事件统一展示:新建客户、发邮件、收邮件、打电话、线下拜访、报价、签约。
数据模型上用了单表继承(Single Table Inheritance)的思路:
CREATE TABLE activities ( id TEXT PRIMARY KEY, customer_id TEXT NOT NULL, type TEXT NOT NULL, -- email_sent, email_received, call, meeting, deal happened_at TEXT NOT NULL, -- 业务时间,不是录入时间 summary TEXT NOT NULL, -- 一句话摘要 detail_json TEXT, -- 详细内容,按type解析 created_by TEXT, created_at TEXT NOT NULL );关键点在于summary字段。我们在所有类型的事件写入时都强制生成一句摘要,比如“向张总发送了产品报价V3,金额28万”。这么做的原因是,时间线上如果只显示邮件标题和电话时长,用户必须点开才能看到内容;而有了摘要,用户扫一眼就能知道发生了什么。这是我们自己用了一段时间才总结出来的优化,信息获取效率提升非常明显。
detail_json字段存储结构化详情,取值根据type不同而不同。比如邮箱类型就存发件人、收件人、邮件正文全文;电话类型就存通话方向、时长、对方号码;会议类型就存会议参与者、地点、纪要。查询历史时只需要查到活动记录这一行,按type解析JSON即可,不用做多表JOIN。
3.3 邮件自动归集的实现细节
邮件自动归集是整个DeskcommCRM里最容易出错但最提效的模块。销售再也不用去邮箱里翻历史邮件,打开客户详情页就能看到所有往来记录。
实现逻辑分几步:
- 配置邮箱账号(IMAP服务器、账号、密码或授权码),密码通过Electron的
safeStorage加密存储在本地 - 后台定时轮询(默认每5分钟)新邮件
- 对每一封新邮件,解析发件人地址和邮件主题
- 在本地客户库中查找匹配的客户
- 匹配成功则写入
activities表并关联到客户;匹配失败则进入“未关联邮件”列表,由用户手动关联
匹配规则是重点。一开始我们只按发件人域名匹配,比如发件人是zhangsan@company.com,就在客户资料里找邮箱域名含company.com的记录。但是很多客户的邮箱域名可以不对应,比如客户用qq.com个人邮箱联系。后来增加了两个辅助规则:
- 邮件正文里出现的公司名称关键词匹配(比如邮件签名里有公司全称)
- 按联系人邮箱精确匹配(客户下的联系人表里有具体邮箱地址)
匹配过程会产生误匹配,所以我们对“自动关联”和“手动确认”做了区分。自动关联的邮件在界面上显示一个小标签“AI-匹配”,用户可以一键取消关联。这样可以纠错,同时不至于太打扰。
3.4 任务提醒与销售漏斗的闭环
CRM系统如果没有任务漏斗,本质上就是一个客户通讯录。DeskcommCRM的任务模块围绕两条主线:
待办任务线:每一条活动都可以“生成待办”。比如你给客户发了报价单,系统会提示“是否需要安排一周后跟进?”点击确认后就在任务列表里生成一条跟进任务,设置截止时间和提醒。为了避免打扰,提醒默认只在桌面通知一次,不在系统托盘持续闪烁。销售养成每天上班第一件事看当日待办的习惯,比任何激励都管用。
销售漏斗线:我们将销售阶段定义为七个:线索 → 初步沟通 → 产品演示 → 方案报价 → 商务谈判 → 合同审批 → 赢单。每个客户只有一个当前阶段,阶段变更会记录到时间线上。
漏斗视图是一个简单的横向看板,按阶段分列展示客户卡片。但这里我们没有做拖拽调阶段,而是要求用户进入客户详情页点击阶段变更按钮。这样每一步阶段变更都会强制填写备注并记录到时间线,避免误操作和“不知道谁把客户挪走了”的扯皮问题。
4. 数据库设计与多端同步冲突处理
4.1 本地优先架构:为什么选择SQLite + 增量同步
DeskcommCRM的同步架构我单独拿出来讲,因为这是整个项目里技术含量最高、也最容易被低估的部分。选型方面我们用了SQLite存本地数据,同步服务端用PostgreSQL。两个库不需要表结构完全一样,本地库可以多一些临时状态字段,服务端主要负责合并和分发。
每一条业务数据(客户、活动、任务)都有两个关键字段:
updated_at:业务数据的修改时间updated_uuid:每次修改生成一个新的UUID
同步引擎的做法是:每次启动或手动刷新时,客户端向服务端请求last_sync_time之后发生变更的数据,同时把本地updated_at大于上次同步时间的变更推上去。两边都维护一个sync_clock,记录最近一次成功同步的时间点。
这套机制看起来很像增量同步,但真正写起来会发现很多细节容易踩坑。比如:有些表的记录会物理删除(比如用户删了一条误建的客户),同步时需要把删除动作也传播到服务端。我们的做法是引入“软删除”:所有业务表都增加deleted_at字段,删除时只更新deleted_at和updated_at,不物理DELET。同步时服务端根据deleted_at分发删除信息,其他客户端收到后也执行软删除。
4.2 冲突策略:最后写入胜出还是版本向量
多端同步中,冲突是避不开的问题。在DeskcommCRM的典型场景下,虽然大多数客户只有一个负责人,但偶尔也会出现两个人在同一时间段先后修改同一个客户资料的情况。
我们采用的策略是“逐字段最后写入胜出(Last Write Wins)”,但不是简单的整行覆盖。举例来说:销售A修改了客户的联系电话,销售B在同一时间段修改了客户的地址。如果整行覆盖,后提交的人会把先提交的人的修改也盖掉,这就产生了数据丢失。
我们的做法是把更新粒度降到字段级别:
- 客户端在更新一条记录时,把发生变更的字段名和值打包成一个
changes对象 - 同步时,服务端和本地都按字段级别合并:同一个字段两个时间版本不同,保留
updated_at较新的值;不同字段互不影响
这样修改冲突被控制在字段级别,绝大多数情况下两个销售的修改能天然合并,不需要人工干预。只有极少数情况下(比如两个人都修改了“客户等级”这个字段),才会出现“最后提交者胜出”的直接覆盖。我们接受这个策略,因为在一个销售团队里,同一个客户的同一个字段被两个人同时改本身就是管理流程问题,而不是技术问题。
4.3 同步引擎的幂等设计与断点续传
同步引擎最怕的事情是重复执行。网络传输过程中,一个请求可能已经到服务端了,客户端因为超时没有收到响应,于是重试发送。如果服务端不做好幂等,就会产生重复数据。
DeskcommCRM的同步接口设计遵循一个原则:客户端发来的每一条变更都自带updated_uuid(UUID是每次修改时生成的唯一标识)。服务端在接收变更时,先查询这个UUID是否已经存在,存在则直接返回成功,不再处理。这样即使客户端重试100次,服务端也不会产生两条重复记录。
断点续传的逻辑也很简单:客户端把待同步的变更按updated_at排序,每次最多传200条。服务端处理完返回批次内最大的updated_at,客户端用这个值更新本地 sync_clock后继续推下一批。如果网络中途断开,客户端保留未确认的批次,下次重连继续从last_sync_time开始。整个过程不需要复杂的游标,利用业务数据的updated_at排序天然实现了批处理。
5. 桌面端体验调优与实际部署中的坑
5.1 Electron打包体积与启动速度优化
Electron应用被打包工具折腾过的人都有体会,安装包动辄两三百MB,用户下载安装的体验很差。我们的安装包一开始有180MB左右,主要体积来自Electron框架本身、Chromium内核、以及Node原生模块。
优化动作分了几步:
- 使用
electron-builder的asar模式压缩应用代码,这一步能省一点点,但压缩率有限 - 按平台裁剪Electron二进制:Windows下用
winCodeSign和nsis配置减小体积 - 对原生依赖
better-sqlite3做了electron版本配套的重编译。这一步是个大坑:直接安装的better-sqlite3是给Node用的,拿到Electron里跑会报“NODE_MODULE_VERSION不匹配”,必须用electron-rebuild重新编译原生模块 - 图标资源和示例数据不打包进安装包,首次启动时通过内置脚本生成
最终安装包从180MB降到了110MB左右,虽然和Tauri那种10MB级别的安装包还是没法比,但在Electron生态里已经算比较收敛了。
启动速度方面,销售最不能忍的是双击图标后要等8秒才看到界面。我们用了两个优化:
- 开启V8的代码缓存,让Vue框架代码在第二次启动时能更快解析
- 把本地数据库的初始化放到主进程的异步任务里,先渲染UI骨架,再加载真实数据。UI骨架先出,用户在500毫秒内就能看到界面,数据大概在1秒左右填充完成
5.2 离线可用与弱网环境的同步重试
销售使用DeskcommCRM最频繁的场景之一就是客户现场。会议室网络质量很差,经常是连上了Wi-Fi但丢包率极高。这种弱网环境下同步必然失败,此时不能报错就完事,必须设计自动重试机制。
我们的处理方案是:同步引擎维护一个待同步队列,当服务端请求失败时,按指数退避策略自动重试:延迟分别是15秒、30秒、60秒、120秒、最长5分钟一次。重试期间用户操作不受影响,照常录入数据。只有当队列积压超过200条时,才在界面上提示“本地待同步数据量较大”。
离线期间生成的修改,在界面上用一个小图标标记为“待同步”,用户能直观看到哪些数据还没传到服务端。这点很重要,因为销售会担心“我记的客户信息是不是没有保存成功”,看到状态标记心里就有底了。
5.3 权限与审计:销售数据安全的边界
自托管CRM最容易被忽视的是权限。很多团队用自研系统,“反正只有我们内部用,权限不用做太复杂”。但我们内部信息安全管理要求所有涉及客户敏感信息的操作必须有迹可循。
DeskcommCRM做了一个轻量的审计日志:
- 谁在什么时间查看了哪个客户详情
- 谁导出了客户数据
- 谁修改了报价金额
- 谁删除了一条跟进记录(软删除也留痕)
审计日志本身只写入本地,通过同步任务上传到服务端的独立审计库,客户端无法修改服务端审计日志。这样即使销售在本地手工改数据库,服务端的审计记录也改不掉。
权限模型没有做复杂的RBAC,只有两个角色:普通销售、管理员。管理员拥有全部权限,可以查看所有客户数据;普通销售只能看自己负责的客户。信息安全管理要求我们最小化权限,这个两个角色+审计的组合目前够用。
6. 上线后的数据复盘与后续演进
6.1 团队实际使用情况
DeskcommCRM上线到现在已经跑了将近8个月。团队12名销售加4名售前工程师,总共16人日常使用。客户数据大约有1200多条,关联的邮件活动记录2.6万条,整个PostgreSQL数据库占用不到2GB。本地SQLite单机数据量最大的用户有300MB左右,查询时间线没有明显卡顿。
使用反馈最好的模块是邮件归集和时间线。过去销售要回忆“上个月跟这个客户聊了什么”,现在打开客户详情页一目了然。有销售同事原话说“这个功能让我的周报好写了太多”。周报不再是凭记忆拼凑,直接把本周跟进记录导出就行。
6.2 后续迭代方向
计划中的迭代方向有三个:
- 邮件摘要:通过调用大模型对客户邮件线程自动生成摘要,提炼关键决策点。这个功能原型已经跑通,但考虑到成本和数据安全,还在内部评估
- 移动端只读视图:销售在外面想快速查一个客户的联系人和最近动态,目前只能开电脑太不方便。计划用Flutter做一个只读的移动端,同步走同一套API
- 自定义报表:销售负责人想要按行业、来源、阶段维度统计客户分布,虽然PostgreSQL查起来很简单,但界面还没做
6.3 给其他想自研CRM团队的建议
最后一条心得送给看到这里的朋友。如果你们团队也在纠结自己写CRM,我建议先做一次严肃的现状评估。如果客户量只有几百个、对数据安全要求高、通信数据分散,自研一个轻量级桌面CRM完全可行。如果团队已经有几千上万个客户,涉及大量跨部门协作和复杂权限,建议还是选成熟产品,别在业务系统上重复造轮子。
自研系统最大的成本不是开发,而是维护和迭代。DeskcommCRM能活下来,一是因为它的功能边界非常克制,二是因为我们真的每天都在用它,被自己的工具绑架了,所以迭代动力拉满。如果只是“做个系统给别人用”,大概率做到一半就凉了。这个项目给我最大的收获不是技术本身,而是让我重新理解了“工具为人服务”的含义:CRM的核心不是系统,而是让销售更有效地跟进客户这件事本身。