很多人第一次看到“DeskcommCRM”这个名字,第一反应都是:现在SaaS CRM满地都是,为什么要自己折腾一套桌面端的?说实话,这个决定不是拍脑袋拍出来的,而是被一次客户资料丢失事故逼出来的。
当时团队用的是一款免费在线CRM,销售每天在上面录跟进记录、传合同附件。结果某天早上,平台方一条公告说免费版的数据迁移到新机房,迁移过程中一批历史附件和客户备注彻底丢了,找客服只能恢复最近七天的备份。那天我们才发现,所谓“免费CRM”看起来不要钱,实际上客户资产根本不掌握在自己手里。于是我们决定自研一套以桌面端为主、私有化部署的CRM系统,项目代号就叫DeskcommCRM。这篇文章就把整个从零搭建的过程、架构决策、踩坑记录完整复盘一下,给也想做内外部客户管理系统的团队一个参考。
这套系统的核心定位很明确:第一,客户数据必须100%掌控在自己手里;第二,销售在见客户的路上、在地铁里、在客户公司没网的环境下,必须还能正常录数据;第三,所有操作可追溯,权限严格分级。围绕这三点,我们在技术选型、数据模型、同步机制、部署运维上做了几轮取舍,下面逐个拆开讲。
1. 为什么坚持做桌面端CRM:一次客户资料丢失后的重构
1.1 免费CRM与私人自建系统的本质区别
先说结论:免费CRM和你自己部署的系统,最大的差异不在功能,而在数据的最终归属权。市面上的免费SaaS CRM,客户资料、跟进记录、合同文件全存在别人的服务器上,平台可以因为商业策略调整随时改规则、关接口、清理数据。而私有化部署的CRM,数据库、附件、备份全在你自己的服务器或者本地主机上,没有任何第三方能“远程删掉”你的数据。
我们当时列过一个对比表,把两类方案摊开看:
| 对比维度 | 免费SaaS CRM | 自建私有化CRM(DeskcommCRM方案) |
|---|---|---|
| 数据归属 | 平台方服务器 | 自有服务器/本地主机 |
| 离线可用 | 基本不可用 | 桌面端本地缓存+增量同步 |
| 用户数限制 | 免费版通常有10人、20人上限 | 自己控制,加人只涨性能不涨费用 |
| 功能定制 | 只能等官方更新 | 数据表和业务逻辑可随时改 |
| 长期成本 | 免费版看似0元,但后期必被引流到付费版 | 一次性开发+少量服务器费用 |
| 数据隐私 | 平台可读 | 完全自主控制 |
简单说,免费SaaS CRM就像一个租来的办公室,房东随时可以涨房租、换锁;自建系统是给自己的公司盖了一栋楼,虽然盖楼要花钱花精力,但房子永远是自己的。
1.2 桌面端相比Web端的关键优势
既然SaaS不行,那为什么不直接做一个Web端自建系统,非要搞桌面端?这个问题我们在设计DeskcommCRM时反复权衡过,最终选择桌面端为主、Web端为辅的架构,原因有三个:
第一个原因是常驻在线。销售的工作流决定了他们要频繁打开关闭CRM页面,纯Web系统需要每次打开浏览器、输入地址、等加载。桌面端客户端安装后可以做到常驻后台、开机自启,像一个本地信息的助手一样随时待命,录客户、查资料只需要一键唤起。
第二个原因是离线可用。这是Web系统做不到的,也是我们整套架构里最核心的设计出发点。销售去客户公司开方案会,现场网络环境经常很差,甚至完全没信号,如果系统没法离线录入,客户聊完关键需求,回到公司可能已经忘了大半。桌面端配合本地数据库,可以让销售在没有网络的场景下正常新增客户、记录跟进内容,等网络恢复后一键同步。
第三个原因是性能。本地SQLite查询几十万条客户记录基本是毫秒级响应,相比请求远程接口省掉了大量网络开销。对每天要翻几百条客户记录的销售人员来说,这种体感差异非常明显。
1.3 DeskcommCRM的整体架构概览
整个系统分三层:客户端层(Windows/Mac桌面应用)、同步服务层、数据中心层。客户端负责日常操作和本地缓存,同步服务负责增量数据交换和冲突处理,数据中心层持有全量业务数据并提供管理后台的Web端访问。
客户端用的是Electron加Vue,数据存储用SQLite,同步服务用Node.js编写,数据中心用PostgreSQL加上MinIO做附件存储。之所以客户端和服务端都用Node.js技术栈,是因为团队成员对这个生态最熟,前后端共享一部分数据校验逻辑,减少重复开发。这套架构跑通后,我用一句话总结就是:桌面端是门面,同步层是血管,数据库是核心资产。
2. DeskcommCRM的数据模型与权限控制:先想清楚谁能看谁的客户
2.1 客户、联系人、跟进记录的表结构设计
CRM系统的核心不是界面,而是数据模型。DeskcommCRM的数据模型经过了三次大改才稳定下来,第一次设计得太简单,只有一张“客户表”,后来发现销售场景远比想象的复杂,逐步拆成了五张核心表。
第一张是customers客户表,字段包括客户ID、客户名称、所属行业、客户规模、来源渠道、负责人ID、创建时间、更新时间、自定义字段(JSON格式存储)。第二张是contacts联系人表,记录联系人姓名、职位、电话、微信、邮箱、所属客户ID、是否关键决策人。第三张是follow_ups跟进记录表,每次销售和客户的交互都存一条记录,包含客户ID、跟进人ID、跟进方式(电话/拜访/微信)、跟进内容、下次跟进时间。第四张是deals商机表,存商机名称、金额、所处销售阶段、预计成交日期、赢单概率。第五张是activities操作日志表,所有增删改查操作都留痕。
这五张表通过外键关联起来,形成了一个完整的客户生命周期链路。设计时特别强调了一个原则:业务数据必须带时间戳和操作人。举个例子,客户从“初步接触”阶段转到“方案报价”阶段,如果不记录是谁在什么时候改的阶段和理由,后面做销售漏斗分析时根本无从下手。
2.2 谁拥有这个客户的权限模型设计
权限控制是CRM系统里最容易被低估的部分。我们第一版做得非常简单,每个客户只有一个负责人,只有负责人能看到自己的客户,管理员能看到全部。结果上线两周就出了乱子:销售A休假期间,客户打电话到公司问方案进展,同事B完全不知道这个客户的存在,客户体验很差。
后来调整为三层权限模型:第一层是本人数据,销售只能看自己创建的客户;第二层是团队共享数据,同一个小组成员之间可以互相查看客户信息,方便休假交接和协作;第三层是全员公共数据,管理员设定某些客户为“公海客户”,全公司可见、可认领。判断逻辑放在同步服务端统一校验,客户端不做权限判断,只负责显示服务端允许返回的数据,防止有人绕过客户端直接调接口越权访问别人数据。
这里有一个非常关键的细节:权限的校验必须放在服务端,绝不能只在客户端做隐蔽处理。我们曾遇到过销售直接用接口调试工具请求同事客户数据的情况,幸好服务端有权限校验拦截了,否则就是严重的数据泄露事件。
2.3 自定义字段:让系统适配业务而非业务迁就系统
每家公司的销售流程不同,有的需要记录“客户怎么知道我们”,有的需要填“客户预算范围”,有的要区分“A类B类C类客户”。如果系统不支持自定义字段,就只能硬编码在界面里,每改一次业务需求就要发一次版本。
DeskcommCRM在customers表里预留了一个custom_fields的JSON字段,所有不确定的、随时可能调整的附加属性都往里面塞。同步服务端在推送数据时会把自定义字段的元数据描述一起下发,客户端根据元数据动态渲染表单。比如管理员在Web管理后台新建一个“客户等级”的枚举字段,所有桌面端客户端的录入界面会自动同步出现对应的下拉框,不需要重新打包安装包。
JSON字段虽然灵活,但有个需要注意的坑:没法对子字段做数据库级索引。我们解决的办法是额外建一张custom_field_index表,把需要搜索过滤的关键自定义字段单独拆出来冗余存储,查询时先走索引表,再回表取全量数据。这个方案在20万级数据下表现良好,很实用。
3. 离线优先设计:销售在客户现场没有网络也能完整跟进
3.1 SQLite本地库:桌面端离线能力的基石
离线优先的架构核心,是让客户端本地持久化保存一份完整的业务数据。我们用SQLite做本地存储,理由很简单:它是单文件数据库,不需要单独安装服务,部署在客户端内部零运维成本,而且性能足够支撑十万级甚至百万级的数据量。
具体落地时,每次登录成功后,客户端从服务端拉取当前用户有权限访问的全量数据,写入SQLite。这个“全量”听上去吓人,实际上因为权限模型限制,普通销售能看到的客户就是自己负责的那几百个,同步数据量非常小。管理员账号的数据量会大一些,但是内部使用场景下几千个客户也不成问题。后续新增、修改的数据统一走增量同步,不会每次都全量拉取。
SQLite数据文件的路径选择有个小技巧:不要放在Program Files这类系统受保护目录下,否则Windows权限限制会导致写入失败。我们统一放在%APPDATA%/DeskcommCRM/data/目录下,macOS则用~/Library/Application Support/DeskcommCRM/,跨平台也方便备份。
3.2 客户录入、跟进记录的离线操作流程
离线操作的核心流程由三部分组成:本地写入、队列落盘、网络恢复后回放。
本地写入是第一步,用户在客户端编辑客户信息、添加跟进记录时,系统立刻写入SQLite,界面即时刷新,反馈速度是真正本地应用的手感——毫秒级响应。第二步是数据写进一张叫sync_queue的表,每条变更记录包含操作类型(新增/修改/删除)、目标表名、主键ID、变更内容(JSON格式)、变更时间、变更序号。这张表实际上是一个本地待办队列,也是离线能力的心脏。第三步是网络恢复后,客户端按变更序号从小到大依次把队列里的记录推送到同步服务端,服务端确认成功才从队列里移除。
这里要特别提一下附件离线处理。比如销售在客户现场拍了合同照片,或者收到对方微信传来的产品需求截图,这些图片在离线状态下没法直接传到MinIO对象存储,客户端会先把图片临时存到本地磁盘,同步时再逐个上传并更新对应记录里的附件路径。断点续传这个细节不能偷懒,否则一张50MB的视频文件传一半网络断了,又要从头传一次,体验很差。
3.3 “永久在线”弹窗提醒与自动恢复机制
离线优先不代表断网不管,我们在桌面端状态栏常驻了一个连接状态指示器,绿色代表同步正常,黄色代表离线但有未同步数据,红色代表离线且本地操作被锁定(比如管理员手动开启“强制在线模式”时才会锁操作,一般用户不受限)。
每次状态从红色或黄色恢复到绿色时,客户端会自动弹出轻提示:“网络已恢复,正在同步3条本地变更”。同步完成后,如果存在冲突,再弹“有2条数据存在冲突,请在冲突中心处理”。整个机制纯粹靠网络状态监听和同步队列的状态机驱动,用户不需要手动点击任何同步按钮——这也是DeskcommCRM能和“永远在线”这个属性划等号的原因:虽然本质上是离线优先系统,但用户感知到的体验是无论有没有网,系统都在稳定可用。
4. 数据同步机制:版本号、冲突策略与增量序列
4.1 为什么不能简单用最后写入时间覆盖
第一版同步方案我们想得很简单:每次同步时比较“最后修改时间”,谁晚谁覆盖。结果上线就当头挨了一棒:销售A在上海改了一个客户的电话字段,销售B在北京同时改了同一个客户的负责人字段,按最后修改时间覆盖的话,其中一个人的修改必然被静默吞掉。
后来我们彻底抛弃了字段级“最后写入时间覆盖”的土办法,改为记录级版本号加字段级变更日志的方案。每条记录有一个version字段,每次更新版本号加1,sync_queue里记录的是具体哪些字段发生了变更,以及变更前后的值。同步时服务端对比本地版本号和服务端版本号,如果两边版本的记录基于同一版本演进,直接合并;如果基于不同版本,就进入字段级冲突检测流程。
4.2 增量同步实现:版本号加变更序列
增量同步是整个同步系统的技术核心。分三条路:服务端推倒全量拉取、增量变更同步、确认回执。全量拉取只在首次登录或手动强制刷新时发生;增量同步则靠每台客户端维护一个sync_cursor游标,标记已同步到服务端全局变更序列的哪个位置。
具体实现是这样:服务端有一张sync_events表,每发生一次数据变更就插入一条事件记录,自增序号作为全局顺序。客户端同步时拿着本地游标请求服务端:“把序号大于10000的变更事件给我”,服务端查出这批事件,返回给客户端合并到本地SQLite。客户端应用完这批次之后,把游标推进到这批事件的最大序号,然后把本地sync_queue里的待推送变更发出去。
这个模式的好处是可靠和幂等。即使某次同步网络中断、客户端崩溃,重启后只需要重新请求“大于游标的事件”,已经应用过的数据再次应用也不会重复,因为应用时按主键做upsert,天然幂等。整体设计思路和数据库的主从复制很接近,只是放在了两台弱一致性的设备之间。
4.3 冲突策略:自动合并与人工仲裁相结合
字段级冲突检测的算法其实不复杂:客户端的变更内容里带有字段名和旧值、新值,服务端把旧值和服务端当前值做比较。如果旧值等于当前服务端值,说明这期间没人改过这个字段,直接用新值覆盖;如果旧值不等于当前服务端值,说明这个字段在客户端离线期间被其他人改过,那就产生了一次字段冲突。
自动合并的规则定得很干脆:不同的字段冲突各行其道,比如A改了电话,B改了地址,互不影响,直接合并提交;同一个字段冲突则取“最后提交到服务端的版本”,另一个版本的修改值原样存入conflict_resolutions表,并给操作用户推送提醒“你之前修改的客户XXX的电话字段与其他记录冲突,系统已采用对方版本,你的修改保留在冲突历史中”。
但有些冲突是必须人工仲裁的,比如客户负责人归属变更和商机阶段变更同时发生。我们做了个“冲突中心”页面,列出所有需要人工处理的冲突,管理员可以逐个对比两个版本的值,手动选择保留哪个或合并出一个新值。这个功能开发起来工作量不小,但对一个实际业务系统来说,宁可多做一步人工确认,也不能让数据在无人知晓的情况下被清掉。
4.4 同步性能优化:批量与压缩
同步性能直接决定用户愿不愿意用这套系统。第一版同步时,每推一条变更就发一次HTTP请求,500条记录同步了几分钟,用户体验约等于没做离线功能。优化方案很简单粗暴:改进批量接口,一次最多推送500条变更,每条变更内部再打包成单条同步事务;同时对所有传输JSON做gzip压缩。
优化后,实测同步500条记录只需约3秒(正常家庭宽带下),10000条全量拉取约8秒。即使每天产生几千条变更,客户端也会在后台无感同步,用户根本感知不到数据在上传下载。
5. 私有化部署与“永久在线”的运行保障
5.1 Docker Compose一键部署:从裸机到服务可用
真正让DeskcommCRM实现“永久在线”的,不是单台机器的稳定,而是整套服务的自动恢复和升级能力。部署端我们选了Docker Compose作为默认部署方案,把PostgreSQL、MinIO、同步服务、Web管理后台打包成四个容器,一条命令拉起全部服务。
version: "3.8" services: db: image: postgres:15 volumes: - pgdata:/var/lib/postgresql/data environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} restart: always healthcheck: test: ["CMD-SHELL", "pg_isready -U deskcomm"] interval: 10s timeout: 5s retries: 5 minio: image: minio/minio:latest command: server /data --console-address ":9001" volumes: - miniodata:/data environment: MINIO_ROOT_USER: ${MINIO_USER} MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD} restart: always api: build: ./server depends_on: db: condition: service_healthy minio: condition: service_started environment: DATABASE_URL: postgres://deskcomm:${DB_PASSWORD}@db:5432/deskcomm MINIO_ENDPOINT: minio:9000 ports: - "3000:3000" restart: always web: build: ./web depends_on: - api ports: - "8080:80" restart: always volumes: pgdata: miniodata:每个容器都配置了restart: always,Docker会在容器崩溃时自动拉起。进程级别崩溃由Docker守护,系统级别重启由Docker开机自启守护,这就构成了最基本的“永久在线”保障闭环。
5.2 数据备份:不能只靠数据库导出
部署上线后,备份策略是系统最不能省的一环。每天凌晨两点对PostgreSQL做一次全量备份,同时每隔30分钟用WAL归档做增量备份,保留最近30天的备份文件。另外,MinIO的附件数据用mc mirror命令实时镜像到另一台冷备服务器。
这里有个教训:第一版只做了数据库备份,没有备份MinIO附件。结果一次服务器磁盘故障,客户上传的合同附件丢了大半,数据库里的附件路径指向了一片空,幸好冷备服务器上还有另一份镜像,通过重新回放才补回数据。从那以后,我把备份策略改成了“数据库和对象存储双备份,分文件存异地”,再也没出过这种事故。
恢复演练也建议每季度做一次。光有备份不演练,等于没有备份——真出问题的时候才发现备份文件损坏或者恢复流程不顺手,那就晚了。
5.3 客户端自动升级:让销售不用管版本
桌面端应用最麻烦的问题是客户端升级。传统做法是让用户手动下载安装包重新装,但销售根本不会主动升级,版本越拖越旧,服务端新功能推不下去,老版本还可能出现接口兼容问题。
DeskcommCRM做了一个极简的自动升级器:客户端每次登录时向服务端请求版本信息,发现新版本就下载增量包,下载完成后弹出“升级并重启”按钮,点击后自动替换核心文件,重启继续用。整个升级体验和自动更新一个手机App差不多,用户完全不需要管。
关键实现是增量包而非全量包。Electron应用体积通常有80-120MB,每次全量下载太奢侈。我们做了个简单的二进制差异算法,只下发两个版本之间有改动的文件,增量包大多只有几MB到十几MB,几分钟就能完成升级。
6. 从踩坑中学到的优化经验
6.1 本地时间戳同步冲突:时区是个大坑
第一版同步时,我们在客户端直接用new Date().toISOString()生成本地时间,当作记录的更新时间。看起来没问题,直到有销售出差去了美国,他的电脑时区变了,录的每条客户记录“更新时间”都和美国时间一致。同步回服务端后,这些记录的更新时间比实际时间早了13个小时,直接导致一批按时间排序的报表数据乱掉。
后来把所有时间戳统一改成:本地业务展示时用本地时间转换显示,但存储和传输一律用服务端生成的UTC时间。任何同步事件的时间戳标准都以服务端为准,客户端不自己造时间戳。每次同步后,用NTP校准本地时间偏移量,这样即使客户端机器时间不准,同一批数据的时间相对关系也不会乱。
6.2 SQLite并发写锁:连接池与WAL模式的正确姿势
SQLite默认的日志模式在并发写入时表现很差:一个连接在写,其他连接全部阻塞。早期版本的DeskcommCRM在用户快速点击保存按钮时,偶尔会弹“database is locked”错误。
解决办法三个动作一起来:第一,把SQLite的journal模式改成WAL(Write-Ahead Logging),读写并发能力大幅提升;第二,业务层所有数据库操作并发度控制在1个写连接加多读连接,写操作全部排队通过同一个连接池执行;第三,每次批量写入用事务包住,减少提交频率。
改完这三板斧之后,再也没出现过锁库报错。WAL模式唯一的代价是系统异常断电时SQLite会出现短暂的恢复过程,但配合幂等同步机制,本地部分数据恢复后从服务端再同步一次,整体是安全的。
6.3 全文搜索性能:最后用上了FTS5
客户越多,搜索越慢。刚开始直接在SQLite里用LIKE '%关键词%'做客户名搜索,几千条数据无所谓,到5万条之后,每次搜索要等两三秒,销售代表秒级搜索是零容忍的差评。
痛定思痛后用SQLite的FTS5扩展做了全文索引:把客户名、联系人名、电话、微信、跟进备注内容都建进全文索引表,查询改走MATCH语法。同样5万条数据,搜索响应时间从两三秒降到100毫秒以内,体验完全是两个档次。FTS5还支持中文分词,不过要引入分词扩展,我们选了一个比较成熟的中文分词插件,精准度在内部使用场景完全够用。
6.4 权限模型一开始设计太松:敢让普通销售只读自己的数据
权限这件事,最后再说一个真实教训。第一版权限模型是“销售能看到全部客户”,理由是“大家一个团队,彼此透明一些更好协作”。结果上线一个季度,部门主管发现好几个销售盯着同事手里的优质客户,试图通过后台导出的方式私下联系,客户资源有流失风险。
后来权限收紧成“默认只能看自己的,团队共享必须由主管手动打开”,销售在看同事客户时多了一个显式的“可见,只读”状态。这个改动要获得业务部门的理解并不容易,但数据安全的问题不能让步。软件层面的权限控制只是一部分,更重要的是向团队解释清楚:哪些数据能看、哪些不能看、什么操作会被记录到操作日志里。把规则讲透明了,透明度本身就是销售团队彼此信任的基础。
我个人在跑完这套系统后的最大体会是:做一个CRM,最难的不是把界面做美观,而是把“数据是谁的、谁有权限改、改了什么、冲突了怎么处理”这几个问题想清楚。DeskcommCRM这套离线优先加桌面端的架构,也是围着这几个问题转的。如果你也在纠结要不要自建CRM,建议先别急着写代码,把我们这套数据模型和权限分层的思路拿过去用,把业务流程梳理顺了,系统自然就好用了。