自研轻量级桌面CRM:本地优先、时间线驱动的客户管理工具 1. 麻烦的起点为什么要自研一个不臃肿的 CRM三个月的客户资料管理完全失控直到我把“客服工作台”这几个字重新拆开想了一遍才决定动手做 DeskcommCRM。我们是一个不到 15 人的售后服务团队每天要处理微信、邮件、电话三种渠道的客户问题记录却散落在 Web CRM、共享表格和个人邮箱里。新客户进来时光是找到这位客户“上次到底沟通了什么”就要翻三四个页面有些同事干脆把客户沟通内容写在个人记事本里第二天交接时一脸茫然。当时我们不是没有试过现成方案。先上了一套市面常见的 SaaS CRM功能表格一打开销售漏斗、营销自动化、呼叫中心、商机阶段管理全都有光配置角色权限就折腾了一下午。真正在用的其实只有“客户信息 跟进记录 工单”三个模块花了大价钱买回来一堆我们自己也不会用的功能。更麻烦的是客服人员处理一单客户问题需要在浏览器里开五六个标签页一会儿看客户资料一会儿查历史工单一会儿回邮件网络一旦不稳定整个流程就卡在转圈上。换回共享表格更痛苦。Excel 适合做汇总但它天然不擅长表达“一个客户和我们的多次互动关系”。同一个客户多个人同时跟进时你填一个备注我填一个备注谁改了哪一行根本没有痕迹想按时间倒序看客户的历史记录表格里没有时间线这个概念。客户打过来电话坐席先要在表格里搜索客户搜不到就新建一行填到一半发现这个是老客户于是又开一行最终同一个客户在表里散落着五六行数据谁也不敢删因为不知道哪行是最新的。DeskcommCRM 就是在这样一个背景下立项的。我当时给团队提了三句非常朴素的目标第一所有客户沟通记录必须落在一个地方并且按时间线展示第二软件要能离线打开、秒开不依赖浏览器和网络第三客户数据存在公司自己的电脑上不让第三方平台经手。翻译成技术语言就是一个本地优先的桌面端客户管理工具核心交互是“搜索客户 → 查看时间线 → 记录本次沟通 → 处理工单”其他功能通通不要。后来团队里有人给它起了个解释Desk Comm CRM桌面上做通信记录和客户管理这名字确实比原来想的“客服助手”贴切得多。也要说一下它不做什么避免后面写着写着就跑偏。DeskcommCRM 不做销售漏斗不做邮件营销模板不做 ERP/财务对接权限模型也只有“管理员”和“普通成员”两档。原因很简单10 人小团队根本用不到复杂的组织架构和多级审批流做进去只会增加维护成本。面向的使用者也应该是 30 人以内、偏客户服务和售后支持的小团队这种团队要的不是一套包罗万象的体系而是一个能快速打开、能记录、能追溯的工作台。2. 数据模型是一个 CRM 的灵魂客户、沟通记录和工单怎么设计表结构开始写代码前我在数据模型上花的时间最多。CRM 这类应用界面丑一点可以忍数据如果设计错了后面所有功能都会跟着别扭。我最终敲定的核心实体只有四个客户表 customers、沟通记录表 conversations、工单表 tickets以及组织成员表 users。标签和自定义字段这类扩展信息用 JSON 字段存不去建一堆关联表。2.1 用“时间线”而非“表单”来组织客户信息很多 CRM 的客户详情页是一个大表单上面是客户名称、电话、公司、地址下面是各种可编辑字段。这种设计思路适合录入但不太适合客服场景。客服拿到一个客户时真正关心的不是字段而是“这个客户上次和我们发生了什么”。所以我选择把客户详情页做成一条可滚动的时间线所有 interaction 全部挂在客户下面按时间倒序排列。时间线思想直接决定了表结构。conversations 表里面必须有 customer_id 作为外键每次沟通记录都算作一个独立事件工单 ticket 创建、备注、状态变更也都带 customer_id 和时间戳。这样前端渲染客户详情页时只需要按时间维度把沟通记录和工单流水合并排序即可。为后续扩展留好路将来如果加了邮件附件、短信记录、回访提醒都是往 conversations 里插入一种新类型的记录不用大改 schema。2.2 核心建表语句与字段细节我在 SQLite 里定义的初始结构如下省略了一部分冗余字段但核心关系都保留在这里CREATE TABLE customers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, company TEXT, phone TEXT, email TEXT, tags TEXT, -- JSON 数组例如 [老客户,高优] owner_id TEXT, remark TEXT, deleted_at TEXT, -- 逻辑删除标记 created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE conversations ( id TEXT PRIMARY KEY, customer_id TEXT NOT NULL REFERENCES customers(id), channel TEXT NOT NULL, -- wechat / email / phone / manual direction TEXT NOT NULL, -- inbound / outbound content TEXT NOT NULL, operator_id TEXT, created_at TEXT NOT NULL ); CREATE TABLE tickets ( id TEXT PRIMARY KEY, customer_id TEXT NOT NULL REFERENCES customers(id), title TEXT NOT NULL, description TEXT, status TEXT NOT NULL, -- open / in_progress / resolved / closed priority TEXT NOT NULL, -- low / normal / high / urgent assignee_id TEXT, due_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL DEFAULT member, -- admin / member disabled INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_conversations_customer_time ON conversations(customer_id, created_at DESC); CREATE INDEX idx_tickets_customer_status ON tickets(customer_id, status);字段设计上有几个细节值得单独说。id 我统一用随机字符串而不是自增整数原因有二一是本地数据以后如果想做多端同步UUID/随机 ID 可以避免节点间撞主键二是桌面应用的客户端偶尔需要导出合并数据自增 id 很容易冲突。时间字段全部存 ISO 8601 的 UTC 字符串读出来后再在渲染层转成本地时区。这个习惯是我以前做 Web 项目踩坑踩出来的如果存带时区的本地时间换一台机器一合并时间就能差出几个小时。关于“物理删除”我做了明确限制客户只要有任意沟通记录或工单就不允许物理删除只允许逻辑删除deleted_at 置位。原因很现实客服行业里“删掉客户”可能是误操作也可能是客户投诉后清理记录但团队内部需要保留审计追踪谁能删、什么时候删、为什么删都应该能查。逻辑删除还有一个额外好处后续如果做“客户合并”可以把两个客户的记录迁移到一个主客户 ID 下同时保留历史不会出现数据凭空消失的问题。2.3 索引不是越多越好SQLite 的索引我建得很克制。桌面应用数据量通常在几万到几十万行索引多了占磁盘、拖慢写入没有必要。上面这几条索引覆盖了最常见查询按客户找沟通记录、按客户找工单、按客户名称搜索。其余查询如果发现慢再针对性加索引不建议一开始把所有字段都加上。还有一个小习惯我会给 conversations 表按月份做定期归档。不是删数据而是把超过 6 个月的沟通记录导出成单独的历史库文件主库里只保留近 6 个月。这个归档逻辑可以直接在备份功能里一起做对用户体验没有任何影响但能让主库体积和搜索速度维持在一个健康水平。我在开发环境见过 50 万条沟通记录的主库列表查询依然不慢但 FTS 索引会大不少归档对这部分收益很明显。3. 技术选型Electron React SQLite为什么是这个组合我一直认为技术选型没有绝对的“最好”只有“在当前团队条件下最合适”。DeskcommCRM 当时的技术约束有两个团队里前端工程师足够多没有专门的客户端开发人员交付节奏紧希望在两个月内出可试用的版本。基于这两个条件Electron 成了最稳妥的选择。3.1 先回应一个问题为什么不选 Tauri被问得最多的就是“既然嫌 Electron 包大为什么不用 Tauri”。Tauri 的包体积和内存占用确实比 Electron 好看但那是在你愿意接受代价的前提下。我们团队没有一个人写过 Rust遇到系统 WebView 的兼容性问题时排查成本会非常高Electron 自带 Chromium每个版本的行为几乎一致踩坑的答案在网上随手可查。再加上 electron-builder、electron-updater 这套打包发布链路已经很成熟对一个小团队来说少踩一个坑比少 100MB 安装包重要得多。桌面客户端可选项比较方案包体/内存开发效率生态成熟度适合场景Electron较大高前端技术栈即可极高快速交付、团队以 Web 为主Tauri小中等需 Rust/WebView适配中轻量工具、长期维护且愿意投人PyQt / Tkinter小中低中内部工具、简单表单界面表格里可以看出PyQt 对我来说主要是 UI 迭代效率问题客服工具对交互要求并不低要做全局搜索框、虚拟滚动列表、快捷键面板原生控件实现起来绕来绕去远不如 React 顺手。3.2 better-sqlite3 和主进程/渲染进程边界数据库选型几乎没有犹豫直接上了 SQLite。桌面应用的数据天然是单机型的用一个文件承载全部数据备份就是把文件拷走不需要额外维护一个数据库服务。Node.js 这边操作 SQLite 的库有几个我最终选了 better-sqlite3最重要的理由是它的 API 是同步的。写业务逻辑时可以一行接一行往下写不需要到处 await而且同步调用在单用户桌面应用场景下没有性能危机反而让读写顺序变清晰。Electron 的数据安全边界比 Web 应用更重要。我做了个很明确的规定渲染进程React 页面里不允许直接访问 Node.js API 和数据库文件所有数据操作必须通过 preload 脚本暴露的白名单接口走 IPC 到主进程由主进程统一操作 SQLite。这样即使页面被注入恶意脚本也拿不到底层数据库和用户文件。界面组件不感知数据库的存在只知道调用 window.api.searchCustomers(keyword) 会返回结果。3.3 未来加服务器架构怎么留后路虽然 DeskcommCRM 目前是本地优先但我没把架构写死。数据访问层单独抽了一层 service所有 SQL 都在 service 里IPC 层只是薄薄一层转发。将来如果团队规模变大想把数据集中到一台服务器上只需要把 service 的实现从 better-sqlite3 换成 PostgreSQL 客户端IPC 接口理论上不用改动。UI 层因为只跟 window.api 打交道迁移成本也很低。这就是常说的依赖倒置先写接口再谈实现成本远低于事后重构。4. 主进程、渲染进程和 IPC数据流转链路与并发处理这一节是开发过程中最容易出问题的地方也是桌面应用和传统 Web 应用最大的区别。Web 应用里所有逻辑都在同一套事件循环里运行数据库在远端前端只管发请求。桌面应用不一样Electron 有主进程和渲染进程两个世界数据到底从哪条路径落到本地需要设计清楚。4.1 从按钮点击到写入 SQLite 的完整链路我以“新建一条沟通记录”为例说明 DeskcommCRM 的调用链。用户在客户详情页输入内容点击保存React 组件调用 window.api.addConversation(payload)preload 里的 contextBridge 把这个调用转发成 ipcRenderer.invoke(conversations:add, payload)主进程里 ipcMain.handle 收到事件调用 conversationService.add(payload)service 再往 SQLite 写入。示意图大概是这样React 组件 - window.api.addConversation - ipcRenderer.invoke - ipcMain.handle - conversationService.add - better-sqlite3 - 数据库文件这个链路看起来很绕但每一层都有自己的职责。React 组件不关心数据放在哪preload 只做类型校验和参数大小限制主进程的 service 才是唯一碰数据库的地方。用 ipcRenderer.invoke 而不是 ipcRenderer.send是因为 invoke 返回 Promise天然适合前端拿结果。错误处理也简单service 里 throw Error主进程捕获后把 message 透传给渲染进程前端能弹出可读的错误提示。4.2 并发写入与 SQLITE_BUSY 的实战处理我最开始做的是多窗口并行写入结果在一个周末测试时遇到了经典的 SQLITE_BUSY: database is locked。原因也好理解Electron 主进程虽然只有一个但某些操作比如自动备份、全文索引重建会另开一个数据库连接两个连接同时对同一条记录做写操作时SQLite 会直接报锁。我的解决办法分三层。第一层打开数据库时设置 journal_mode WAL 和 busy_timeout 5000这能让读和写并行同时给写操作一个等待窗口。第二层在主进程内为所有写操作挂一个 Promise 队列保证任何时刻只有一个写任务在真正运行其他写请求排队等待。第三层一次性写入多条记录时用事务包裹比如导入 1000 条客户数据如果逐条 insert 会产生 1000 次磁盘提交事务可以一次性整体提交。// 简单的写队列示例 let writeChain Promise.resolve(); function queueWrite(task) { writeChain writeChain.then(task).catch((e) { console.error(write task failed:, e); }); return writeChain; } // 所有写入统一走 queueWrite queueWrite(() conversationService.add(payload));加了这三层之后我在连续导入了 3 万条历史数据并同时在界面上创建多条记录的测试中没有再遇到一次锁错误。这个教训放在 Web 后端里很常见但桌面端因为大家默认“单用户不会并发”反而容易踩中。4.3 备份与恢复把数据库回滚这件事做稳妥本地优先应用最怕数据库损坏我最担心的也是这个。桌面端没有云数据库的自动巡检一旦磁盘异常或应用强制退出导致写入中断用户可能丢失大量记录。所以我从第一个版本就加入了自动备份机制每次应用退出时把 SQLite 文件复制一份到 backup 目录保留最近 7 份每次升级前也强制做一次全量备份。备份文件名带上时间戳例如 deskcomm-2025-03-14-1830.db恢复时用户在设置页选择一个备份文件程序会先备份当前数据库再替换工作目录中的主库文件。使用 WAL 模式后有一个小陷阱直接复制 .db 文件可能不包含 WAL 中尚未 checkpoint 的数据。稳妥做法是用 SQLite 的 backup 命令或者先执行一次 PRAGMA wal_checkpoint(FULL) 再复制文件。我用的是 better-sqlite3 的 backup 方法官方库封装好了不容易出错。5. 搜索与列表渲染两个让我翻车的性能瓶颈功能开发到中期客户数据大概有 2 万多条沟通记录小 10 万条界面开始出现肉眼可见的卡顿。最明显的两个问题一个在搜索一个在渲染都发生在用户每天都用的高频路径上。5.1 第一次踩坑全表 LIKE 搜索最初的搜索实现很粗暴客户名和手机号都走 LIKESELECT * FROM customers WHERE name LIKE % || ? || % OR phone LIKE % || ? || % OR company LIKE % || ? || %;数据量小的时候这个查询毫秒级返回。到了 2 万行以上每次敲键盘都要等五六百毫秒。原因很清楚LIKE 带前导通配符SQLite 没办法走普通索引只能全表扫描。用户输三个字符就触发一次搜索体验非常差。我当时第一反应是加前缀索引也就是不写 %keyword%只写 keyword%让索引能命中。但实际场景是用户会在客户名中间输入关键词前缀匹配并不能解决需求。真正解决问题要用全文检索。5.2 用 SQLite FTS5 trigram 解决中文搜索SQLite 自带的 FTS5 扩展支持全文索引我建的虚拟表长这样CREATE VIRTUAL TABLE customers_fts USING fts5( name, company, phone, email, tokenize trigram );trigram 分词器是 SQLite 3.34 以后内置的它的特点是把文本拆成连续三个字符组成的 token对中文搜索非常友好。中文没有空格分词如果只用默认的 unicode61 分词器“张三”这种词会被当成一个 token搜“张”或者“三”都匹配不上而 trigram 会把“张三”拆成“张三”和“三”相关的三字符组合再配合子串匹配中文场景基本可用。为了让客户表的数据和 FTS 索引保持同步我用触发器在 customers 表插入、更新、删除时同步更新 customers_ftsCREATE TRIGGER customers_ai AFTER INSERT ON customers BEGIN INSERT INTO customers_fts(rowid, name, company, phone, email) VALUES (new.rowid, new.name, new.company, new.phone, new.email); END;搜索时改成查询 FTS 表再回表取完整客户信息。实测 2 万客户数据下搜索响应从几百毫秒降到了十几毫秒体感是“按下去就出结果”。代价是索引文件膨胀大概增加了 20% 的磁盘空间还在接受范围内。ticket 的搜索没有单独建 FTS因为工单量相对小LIKE 搜索仍然能抗住。5.3 时间线列表渲染虚拟滚动与数据切片第二个瓶颈是客户详情页的时间线。一个老客户可能积累了上千条沟通记录一次性全部渲染成 DOM 节点页面滚动就会掉帧。这个问题的标准解法是虚拟滚动我用了 tanstack/react-virtual只渲染视口附近的几十条记录列表总长度用占位高度撑住。改造之后哪怕是 5000 条记录的时间线滚动也保持在 60 帧。还有一个容易被忽视的细节时间线数据拉过来时我做了分页加载一页 50 条滚动到底部再加载下一页。直接在前端合并数据然后用 useMemo 排序。这个组合看起来简单但确实避免了首屏加载大量数据和长时间的白屏等待。6. 打包、自动更新和敏感信息保护做桌面产品绕不开的三件事功能做完连测试环境都跑得很稳定我以为上线的活很简单。结果真到了打包和发布阶段才知道桌面产品跟 Web 产品完全不是一个套路。这里挑三件让我印象最深的事来说。6.1 electron-builder 打包与 Windows 代码签名打包用的 electron-builder配置项主要关注了安装目录、图标和 NSIS 安装逻辑。最开始我没配图标打包出来是 Electron 默认的 logo发给同事试用时被嘲笑了半天。后续把 icon.ico 和 installerIcon 都补齐了专业感立刻不同。Windows 桌面应用还有一个绕不开的关卡是 SmartScreen 拦截未签名的应用首次运行会弹蓝色警告页对于内部工具影响不大但如果是给一个对外交付的小产品还是建议买一个代码签名证书安装信任成本会低很多。实际上我没在一开始就买签名证书。原因很简单团队内部测试阶段每个人都从网盘下安装包大家知道自己装的是什么警告页点“仍要运行”就行。等产品确实要给外部团队试用时再补签名能把声音降到最低。6.2 自动更新的两种落地方式自动更新我首先排除了 electron-updater 默认的 GitHub Releases 方案因为团队网络环境不稳定下载大文件容易失败。最后用的是自建更新服务器配 electron-updater 的 generic provider。更新服务其实就是一个静态目录里面放 latest.yml 和安装包客户端启动时请求最新版本号有新版就提示下载并替换。生产环境里我把启动检查放在应用启动后 3 秒避免和开机加载抢带宽。设置页还加了一个“手动检查更新”按钮供用户在需要时自行触发。打包发布这个完整动作我用一个脚本一次性生成 Windows 安装包和 macOS dmg然后推送到更新服务器。更新推送初期最担心的“新版本有 bug”用保留前一版安装包的方式兜底用户回滚只点一次就行。6.3 用 safeStorage 保护本地凭证而不是明文存 tokenDeskcommCRM 虽然没有账号密码体系但后续接了一个企业邮箱的发送功能需要保存 IMAP/SMTP 的用户名和授权码。第一版我图省事把授权码直接写进本地配置文件后来意识到这是严重的安全隐患任何能读到磁盘的人都能拿到邮箱凭证。Electron 提供了 safeStorage API底层在 Windows 上是 DPAPI、macOS 上是 Keychain、Linux 上是 libsecret可以把字符串加密后存在本地。代码用法很直接const encrypted safeStorage.encryptString(smtpPassword); fs.writeFileSync(secure.bin, encrypted); // 读取时解密 const decrypted safeStorage.decryptString( fs.readFileSync(secure.bin) );加密后的文件即使被别人拿走没有当前系统身份也解不开。这个改进成本不高但价值观很重要。给用户做本地优先工具的意义不就是让数据主权回到用户自己手里吗那本地凭证当然也要用系统级安全能力保护起来。6.4 崩溃恢复与日志桌面应用崩溃了用户第一反应是“我的记录丢了没”我加了两个保护。一是在主进程捕获 uncaughtException 和 unhandledRejection把堆栈写入本地日志文件同时弹窗告诉用户数据已自动保存建议重启。二是每条沟通记录在点击保存时先写入内存的操作日志SQLite 提交成功后再清掉如果异常退出下次启动时会检测未完成的操作日志提醒用户。这种机制没法覆盖所有崩溃场景但给用户的感知是“这软件知道会出问题并且有预案”信任感会明显提升。7. 上线后的真实反馈与下一轮迭代方向DeskcommCRM 上线试运行一个月团队里从最开始的“这玩意能用吗”变成了“今天有没有新版本”。我复盘了一圈最大的收获不是技术实现而是验证了“本地优先 以沟通记录为核心”这个定位确实踩中了痛点。7.1 用户真正的高频动作CtrlK 与快速定位上线后统计了菜单点击次数居高不下的是一个看起来不起眼的全局搜索框默认快捷键 CtrlK。坐席接到电话时第一反应就是按 CtrlK输入客户名或手机号回车进详情页。这个动作在一天里反复发生几十次。之前大家的操作路径是“打开 CRM 页面 → 等加载 → 在新标签页搜客户”每一步都有停顿现在集中在 2 秒内完成。另一个人气功能是客户详情页的“一键复制联系方式”。听起来很傻但客服打电话时经常要把手机号复制到话机拨号软件里每次选中右键复制很繁琐。加了一行小按钮后同事反馈“终于不用从一堆文案里找号码了”。这个需求在需求文档里永远不会被提出来只有真正坐在客服工位上才能体会得到。7.2 被积极反馈背后的“反直觉”教训功能必须克制也有人问“为什么不做邮件营销模板为什么不做销售报表”我都回答暂时不做。上线初期用数据说话一个 10 人客服团队每天真正产生的有效操作大约 300 到 500 次集中在搜索、新建沟通记录、变更工单状态三个动作上。其余管理、报表、统计分析加在一起不到 5%。如果我把精力平均分配去做一堆报表核心体验一定会被拖垮。这个教训让我在后面定了一个原则每一次新功能必须能有具体的、可观察的使用场景否则宁可不做。比如后来加的“客户合并”功能是因为有同事反馈同一客户被建了两条记录合并记录时需要保留历史时间线这才值得动手。7.3 下一轮扩展本地能力优先同步能力看情况如果真的要把 DeskcommCRM 给更多团队用我下一个想做的不是云同步服务而是更扎实的本地能力比如更完整的导入导出、Excel/CSV 一键迁移、更多渠道消息的自动抓取和归档。云同步需要一台稳定服务器、需要处理冲突、需要用户注册账号每一步都是成本目前阶段不值得投入。等有足够多外部团队愿意付费使用时再基于现有的数据访问层接口去实现同步架构上也是立得住的。当然那是另一个项目要聊的话题了。8. 一些容易被忽略的桌面端开发经验留给后来的人这些内容不在任何正式文档里是我做 DeskcommCRM 过程中实际踩过、也反复确认过的经验。8.1 SQLite 文件的备份时间要比你以为的更早我是在数据量到了 5 万条左右才在测试环境模拟了一次“数据库文件损坏”的恢复流程。实际上最好从刚建表时就接入自动备份因为 SQLite 数据库一旦损坏手动修复的手段非常有限最可靠的方案就是拿备份文件恢复。开发阶段的数据没什么价值但养成“每次版本发布前自动备份”的习惯以后线上事故被拖的概率会小很多。备份文件我用 zstd 压缩后上传到本地另一台设备跨机备份比同机备份更稳。8.2 IPC 接口不要暴露得太大能收敛就收敛最早我为了图方便preload 里把 database 相关的 service 对象几乎整个暴露给了渲染进程。结果 React 代码里有人不小心调用了 window.api.db.raw(DELETE FROM customers) 之类的能力虽然那次是在测试环境但还是把我吓出一身冷汗。后来把所有 IPC 接口改成语义化方法比如 searchCustomers、addConversation、updateTicketStatus渲染层只能调用白名单里的函数不能直接执行 SQL。接口数量多了以后也建议做一个统一的 api.d.ts 类型声明前端在写代码时就能得到类型提示而不是运行时报错才发现。8.3 多窗口和单窗口的选择直接决定并发复杂度最初我尝试过“客户列表一个窗口 客户详情一个窗口”的多窗口方案纯粹是想让坐席同时看两个客户。结果多窗口带来的数据库并发、焦点同步、窗口间通信问题比它带来的收益高得多。后来我把所有内容收拢到单窗口内用 React Router 管理页面快捷键切换“列表页 / 详情页 / 搜索弹层”并发写入的问题也一起消解了。单窗口对桌面应用来说是一种降低心智负担的选择能单窗口就单窗口。8.4 添加新的通讯渠道时先在 conversations 表里想清楚字段微信、邮件、电话都能进时间线但它们的元数据差异很大邮件有主题和正文电话有通话时长微信有文本和图片消息。如果只用一个 content 字段存纯文本将来遇到图片消息就没法处理。我最终用 content 存描述性文本额外加一个 metadata JSON 字段存渠道特定的完整信息比如邮件的全文 HTML、微信消息的附件 URL。这样渠道再新增表结构不需要变动只是 metadata 的 schema 变多一点。做 DeskcommCRM 这个项目前后最大的感想可以浓缩成一句话不要把桌面工具当成 Web 系统的迷你版它自己有一套关于数据安全、启动速度、离线能力和交互节奏的规则。每次决策回到“坐席真正坐在电脑前用什么方式最顺手”这个问题上很多纠结自然就解开了。现在团队已经离不开这个工具我也很少再改它的底层结构了小步迭代、保持克制大概就是这个项目能稳定走下去的原因。