ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

轻量级CRM自建实战:Node.js+Electron+SQLite完整指南

轻量级CRM自建实战:Node.js+Electron+SQLite完整指南 1. 项目背景为什么我在2024年决定自建一个CRM先说说我为什么捣鼓这个项目。事情起因很简单去年年初给朋友的一个小团队做客服系统选型试了一圈市面上的CRM要么是定价贵得离谱按坐席数收费一个账号一年好几千小团队根本用不起要么是功能堆得太多客户要的只是“把消息接进来、分给不同的人处理、别漏单”结果打开后台一看什么线索打分、销售漏斗、业绩看板、营销自动化一堆用不上的模块全塞在一起光是配置就花了两周最后大家还是回到Excel和微信群里接单。后来我索性自己动手基于Node.js从零写了一套轻量级CRM代号就叫DeskcommCRM。这个名字是“Desk”和“Comm”的组合直白点说就是“桌面端的通信型客户关系管理系统”。做出来的效果出乎意料地好团队从测试到正式使用只用了一个周末后面半年里陆续加了邮件渠道、工单流转、客户历史记录自动归档稳定运行到现在。这篇文章就把整个项目的设计思路、核心模块实现、坑点和实战经验全部拆开讲一遍。如果你也有类似的需求——给中小团队做一套够用、不贵、能自己掌控的客户管理工具或者只是对CRM系统内部怎么工作感兴趣这篇应该能给你不少可落地的参考。DeskcommCRM解决的核心问题用一句话概括就是把散落在邮件、表单、企微等渠道的客户消息统一收进来转成结构化工单分配给具体负责人并且让每一次沟通记录都能被追溯。它不是要替代Salesforce那种重型军火而是做一把趁手的匕首。2. 整体架构与设计决策轻量不等于简陋2.1 选型逻辑为什么是Node.js Electron SQLite说白了这个项目的定位决定了技术选型。目标是给一个10人以内的小团队用部署在局域网或一台小型服务器上不需要复杂的分布式架构。那我选型的原则就很明确开发效率优先部署成本尽量低运行依赖越少越好。后端用Node.js是因为前端本来就要做Web界面前后端统一用JavaScript不需要维护两套语言。Express作为路由层够轻够稳。数据库选SQLite而不是MySQL或者PostgreSQL是因为这个量级的数据并发根本到不了需要独立数据库服务的地步SQLite单文件存储、零配置文件、随手备份就带走对小型团队来说太合适了。实际运行下来几千个客户、几万条工单的记录量SQLite查询响应依然是毫秒级完全够用。客户端壳子用了Electron。这样做的好处是团队成员不用记IP地址、不用开浏览器输账号桌面上一个图标点开就是工作台实时性也更好——因为Electron能把WebSocket常驻连接保持得更好消息一到操作系统就弹通知。这个组合不是没有缺点。Electron的内存占用、打包体积一直是被吐槽的点但在内网办公场景里、8GB内存的普通办公电脑上跑体感并不明显。相比重型的Java Vue MySQL全家桶这个方案一个人两周就能做完初版性价比极高。2.2 模块边界设计七个核心模块与职责划分DeskcommCRM的后端代码划分成七个模块每个模块只做一件事模块之间通过事件总线和数据库表进行解耦。渠道接入层Channel Adapter负责监听各来源的消息包括邮件IMAP拉取、Web表单提交、企微/钉钉回调等。新渠道可以按适配器模式扩展不影响现有逻辑。工单引擎Ticket Engine将原始消息分类、去重、生成工单维护工单的状态待处理/处理中/已完成/已关闭和优先级低/中/高/紧急。客户聚合Customer Hub按手机号、邮箱等唯一标识聚合客户信息自动合并历史记录生成客户360°视图。分配调度Assignment基于预设规则轮询、负载最小、关键词匹配将新工单分配给人或队列。通知中心Notification内部站内信、桌面弹窗、邮件通知三种方式确保“有人对工单负责”。操作审计Audit Log记录所有关键动作——谁在什么时候改了客户状态、谁回复了什么内容防止扯皮。数据看板DashboardSQLite聚合查询统计响应时长、工单量趋势、个人工作量等基础指标。这个划分是我在写了两版废代码之后才定下来的。第一版我把所有逻辑塞在几个大路由文件里看起来代码少但每加一个新渠道就要改三四处地方很快变得不可维护。重构之后新增一个渠道只需要实现Channel Adapter的接口——一个checkNewMessages()方法和一个normalizeToEvent()方法——其余全部复用。2.3 数据模型设计一张工单表和一张客户表如何撑起全部业务数据库表结构没有过度设计。核心只有五张表customers客户、tickets工单、messages消息记录、users用户/坐席、audit_logs审计日志。加上一张channel_accounts渠道账号配置一共六张。最关键的是tickets表的设计。每一条消息进来先判断是否属于已有客户再判断是否需要创建新工单。判断逻辑很简单如果该客户存在“待处理”或“处理中”状态的工单并且当前消息与该工单时间间隔在24小时以内就归入原工单否则新建工单。CREATE TABLE tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_no TEXT UNIQUE, customer_id INTEGER NOT NULL, channel TEXT NOT NULL, subject TEXT, content TEXT, status TEXT DEFAULT open, priority TEXT DEFAULT medium, assignee_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, closed_at DATETIME );这个结构简单到不能再简单但支撑了所有核心流程。工单和消息是一对多关系客户和工单也是一对多关系。查询“这个客户的所有历史”就是一条WHERE customer_id ?的JOIN不需要任何复杂设计。关于为什么没有用外键约束多说一句。SQLite默认外键是关闭的需要执行PRAGMA foreign_keys ON。我直接没开所有关联关系在应用层保证。数据量小的时候这个做法省心不少——至少不用面对外键约束导致的插入顺序问题。小团队场景下可靠性完全够用。3. 核心功能模块解析工单、统一收件箱与客户视图3.1 统一收件箱让所有渠道的消息在一个界面里流动这个功能是DeskcommCRM最日常的部分也是团队感知最强烈的一个功能。统一收件箱说白了就是把原来需要切来切去的邮箱、企微、表单后台全部折叠到一个页面里。实现上有一个关键点消息的实时获取。我当时对比了两种方案。方案A轮询。前端每5秒钟请求一次/api/tickets?statusopen代码量最少但有一个问题是消息延迟最高能到5秒而且频繁请求对服务器和网络都是无谓消耗。方案BWebSocket长连接。服务端用socket.io推送新工单和状态变化前端监听事件即时刷新列表和桌面通知。延迟基本在200毫秒以内用户体验好很多。最终两个方案一起用了WebSocket作为主通道页面每次加载时做一次HTTP全量同步另外每隔30秒做一次静默轮询作为兜底防止WebSocket断连后状态不同步。这种“双通道”设计在真实办公网络环境里特别重要——公司的Wi-Fi每隔一段时间会掐掉空闲的长连接这时静默轮询能把状态补回来不至于漏掉消息。收件箱的列表页做了三列布局左侧是工单列表按时间倒序未读加粗显示中间是消息详情和回复编辑区右侧是客户信息卡片。桌面上再配一个Electron的未读角标。整个交互流程非常顺滑。3.2 工单生命周期从创建到关闭的状态机设计刚开始写工单状态流转的时候我特别容易犯错总是忍不住画出特别复杂的状态图——什么待认领、处理中、等待客户回复、已解决待关闭、已关闭、已归档再加上各种条件判断代码写着写着就懵了。后来我砍掉了大部分状态只保留四个open待处理、pending等待客户或他人回复、resolved已解决待确认/已确认、closed关闭归档。所有状态流转都是单向推进不允许乱跳const stateMachine { open: [pending, resolved], pending: [open, resolved], resolved: [closed], closed: [] // 终态不可再修改 };这个状态机不像是“规范设计”出来的更像是从团队实际工作方式里长出来的。小团队处理客户问题就三种情况刚收到还没动open、回复了客户在等对方pending、搞定了resolved。保持简单的好处是——团队成员不需要培训看名字就能知道什么意思。代码实现上状态变更统一走一个transitionTicket(ticketId, newStatus, userId, reason)方法内部先校验是否允许当前状态跳转到目标状态再更新数据库、写入审计日志、触发通知事件。到这一步状态机的好处就体现出来了不管业务逻辑怎么改底层的数据流转不会乱。3.3 客户聚合与360°视图基于唯一标识的查找合并客户聚合模块说难不难说简单也藏了不少细节。逻辑核心是每来一条消息提取发件人邮箱或手机号拿这个标识去customers表里查查到就更新最后联系时间查不到就用标识新建一条客户记录。第一次联系时如果带了姓名就存下姓名没有的话就先存一个“未知客户”等客户回复时再补全。实际做的时候遇到一个比较棘手的问题同一个人第一次用邮箱发来咨询第二次用企微联系两次是不是同一个客户判断不了。我最后用了一个简单粗暴的策略允许通过管理界面的“合并客户”功能手动把两条客户记录合并。合并时保留最早创建的一条作为主记录把另一条的所有工单、消息全部挂到主记录下删除被合并记录。这个功能看起来可有可无但实际用了两周之后团队反馈“这是最香的功能之一”。因为真实业务里客户不会规规矩矩用同一个联系方式没有合并功能客户历史记录就是断裂的每次都要重新问一遍背景信息。客户视图页面显示五块内容基本信息姓名、联系方式、标签、关联工单列表按时间倒序、全部沟通记录按渠道过滤、备注时间线、以及最后一条消息摘要。所有内容通过一次API请求返回避免前端多次加载。3.4 自动化规则用简单的JSON表达式代替流程引擎很多人一说到自动化就会想到各种Flow引擎、节点编排工具。但DeskcommCRM的自动化规则只是一组由条件加动作组成的JSON配置。规则配置文件长这样{ rules: [ { id: rule_auto_assign_tech, name: 技术类问题自动分给工程师, when: { field: ticket.subject, op: contains, value: 技术支持 }, then: { type: assign_to_tag, target: engineer } }, { id: rule_auto_reply_receipt, name: 夜间提交自动回复收据, when: { field: ticket.channel, op: equals, value: web_form }, then: { type: send_email_reply, template: auto_receipt } } ] }执行引擎收到新工单后把工单字段和每条规则逐一匹配匹配上的就执行对应的动作。为了避免规则之间互相打架每个工单最多执行三条规则按照规则的优先级字段排序。这个设计相比真正的流程引擎功能弱了不少但胜在任何人花10分钟就能看懂。小团队的自动化需求本质上是极其有限的几个场景自动分配、自动回复、自动打标签、超时提醒。把这些场景用简单的条件和动作覆盖好比做一个通用流程引擎有用得多。这也是整个项目里反复验证的一个理念——80%的复杂设计换个角度看可能就是20%的必要需求加80%的自嗨。4. 实操记录从零部署DeskcommCRM的完整流程4.1 环境准备与数据库初始化整个部署过程我压到了一个小时内。服务器的环境要求极低一台Linux或Windows机器Node.js 18npm。不需要装数据库不需要配Redis不需要Nginx反向代理可选。初始化命令git clone https://github.com/yourname/deskcommcrm.git cd deskcommcrm npm install cp .env.example .env node scripts/init-db.js npm startinit-db.js做的事情很单纯创建SQLite数据库文件、执行建表语句、写入初始管理员账号。这在项目初期甚至不需要一个独立的脚本——我直接在npm start里检测数据库是否存在不存在就自动建表。之所以最终保留了单独的命令是为了让不想用默认配置的人有机会先改.env再初始化。4.2 渠道接入配置IMAP收信、Web表单、Webhook回调一个CRM如果只能手动在后台录入客户那价值就折了一半。DeskcommCRM的渠道接入是它最出彩的部分。IMAP收信配置在.env里配置邮箱的IMAP服务器、端口、账号和授权码系统会每60秒拉取一次收件箱把新邮件转为工单。这里有个容易踩的坑很多邮箱服务商想在第三方应用上使用IMAP必须生成专用授权码而不是直接用登录密码不然一直报AUTHENTICATIONFAILED。还有就是要注意IMAP的uid处理和已读标记否则容易重复拉取同一封邮件。Web表单在网页里插入一段JavaScript脚本表单提交后POST到DeskcommCRM的/api/webhook/form接口系统自动创建工单并触发规则。这段脚本本身很小甚至不需要引入SDK直接用fetch即可。Webhook通用入口对于企微、钉钉这类平台各自都有不同的消息格式和签名规则。我在Channel Adapter层为每个渠道写了一个适配器每个适配器只负责将平台的消息格式转换成内部统一的channel_event结构然后丢进同一个消息队列。这样上层的工单引擎和客户聚合完全感知不到底层渠道差异。4.3 多用户与权限控制谁能看到什么数据10人以内的小团队权限模型没必要搞RBAC那么重。DeskcommCRM使用了简化版的三级权限管理员admin全部权限包括系统设置、渠道配置、用户管理、合并客户。坐席agent查看和操作分配给自己的工单查看客户信息回复消息。只读viewer只能看数据看板和被明确分享的工单不能修改任何内容。权限控制在Express中间件里实现每个请求经过requireAuth和requirePermission(ticket:update)两层检查。前端再根据当前用户角色决定显示或隐藏操作按钮。这里顺便吐槽一句前后端都要做权限校验的原因——如果只做前端控制任何一个懂点DevTools的人都能绕过只做后端控制前端的体验会很差按钮倒是能点但每次都要被拒。两边都做代码量也不大但可靠性提升一个档次。权限这部分最值得分享的经验是权限不是越多越好。我见过很多团队把权限分到字段级别——谁能看客户手机号、谁能看聊天记录、谁能导出数据配置页面做成五行八列的矩阵结果管理员自己都记不住。DeskcommCRM的用户权限属于“够用就行”的类型实际使用两周后团队从没因为权限问题困扰过。4.4 桌面通知与离线消息处理Electron壳子的工作方式Electron壳子的主进程负责三件事创建窗口、保持WebSocket连接、调用系统通知API。窗口本身只是一个加载本地Web服务器的BrowserWindow。这样做有个好处服务器在远程时团队成员可以只开浏览器访问不依赖壳子壳子起到的作用主要是一个更好的“入口”和系统级通知能力。离线消息的处理策略是WebSocket断线重连时服务端会把从上次断线到现在的消息变更一次性推给客户端客户端再对比本地缓存做增量渲染。这个逻辑初版是用时间戳实现——客户端每次收到消息时记录last_received_at重连时带上这个时间戳服务端返回之后的所有变更。实际操作中发现如果系统时间不一致或消息并发到达时间戳可能漏消息后来换成了由服务端维护的单调递增的消息序号。5. 典型问题排查与避坑指南5.1 我实际踩过的三个坑**第一个坑是SQLite的并发写入。**初版代码里所有数据库操作用的是同一个连接。当多个渠道消息同时到达时Node.js的单线程模型确实不会导致数据错乱但SQLite本身一次只允许一个写操作其余的写请求会排队。如果某个瞬间写入请求特别多会出现SQLITE_BUSY的错误。解决方法是开启WAL模式并把busy_timeout设置为3000毫秒db.pragma(journal_mode WAL); db.pragma(busy_timeout 3000);开启WAL模式之后并发读写性能显著提升再也没有出现过SQLITE_BUSY。这个操作简单到只需要两行代码但对小团队的体验提升非常大。**第二个坑是IMAP重复收件。**邮件拉取必须记录已处理过的Message-ID我最初是靠拉取时间戳来做去重但如果你在邮件服务器上手动把一封邮件从“已读”改成“未读”时间戳策略就会重复建工单。后来我在messages表里为channel_message_id字段加了唯一索引插入时冲突就跳过。**第三个坑是Electron的跨域安全限制。**开发环境下前端跑的localhost:3000Electron壳子加载的页面可能跑在localhost:3001会产生跨域问题。解决方案是开发时配置webSecurity: false方便调试但生产环境千万不要关。正确做法是在主进程里配置session.defaultSession.webRequest.onBeforeSendHeaders把Authorization头注入到请求中。5.2 排查技巧先查日志还是先查数据库跑了一段时间后我养成了一个固定的排查习惯遇到问题先看三样东西按顺序排查。第一是查看logs/app.log那里打印了所有关键操作的事件记录包括工单创建、状态流转、消息推送等。第二是看audit_logs表能快速确认用户层面发生了什么比如某个工单是不是被某个人误操作改了状态。第三才是看实际的业务数据表。这个习惯帮我避免了很多弯路。比如有一次用户反馈“消息没有通知”我第一反应是查WebSocket连接状态查来查去都没发现问题。后来才想起来看一眼WebSocket推送服务对应的日志——终端显示推送目标是一个已经不存在的userId。原因是该用户离职后账号被删除但分配规则里还残留着旧ID。这种问题如果一开始就翻审计日志五分钟就能定位。5.3 数据备份与迁移SQLite的简单方案SQLite数据备份是我最喜欢跟别人聊的部分。很多团队一听SQLite就觉得不靠谱但实际上只要做对策略SQLite的备份和恢复比MySQL更省心。由于整个数据库是一个单一文件备份操作就是复制文件。我设置了两个定时任务一个每小时把deskcomm.db复制到backups/目录下保留最近48份另一个每天凌晨对数据库执行一次VACUUM INTO ./daily_backup.db生成一个完全一致的快照。迁移也一样简单停服、复制文件、启动新机器上的服务整个过程不超过2分钟。加上SQLite不需要额外授权、内存占用低对于5-10人的团队这比部署MySQL少操心一个数量级。6. 后续演进路线与个人体会DeskcommCRM开发到现在功能基本稳定但我心里面还列了几个目前没有做、但值得后续迭代的方向。第一个是邮件回复模板的变量系统。目前的邮件回复是纯文本拼接虽然能工作但每次都要手动输入客户姓名和工单编号体验还是有提升空间。准备做成模板里支持{{customer.name}}、{{ticket.no}}这样的占位符保存的时候自动替换。第二个是SLA超时提醒。也就是工单如果超过了设定的响应时限自动升级优先级并把通知推给管理员。这个功能在小团队里可能不太常被用到但我认为做到“每周只需看一眼就能确保没有漏单”的状态才是这个系统最健康的形态。第三个是移动端适配。现在微信端团队群偶尔会问“这个工单谁在处理”如果能在微信端查看简版工单列表效率会很高。不过目前团队的移动办公需求不够强烈这个排期一直比较靠后。最后说点个人体会。做完这个项目我最大的感受是技术选型不必追求“当下最先进”而应该追求“在当前约束条件下最合适”。DeskcommCRM用的每一款技术都不算新潮但组合在一起做到了让一个只有三个人的团队能维护、能让十个用户稳定使用、能一周开发出初版这就是它的价值。我后面几次给别的团队推荐系统时尽管他们预算是够买商业CRM的我依然会把这套自建方案的代码仓库丢给他们看。不是因为这套代码有多牛而是因为它验证了一件事——对于很多中小团队来说一个能亲手摸到底、能按需改、能本地部署的轻量系统可能比功能齐全的庞然大物更有用。
返回列表