2026/9/18 8:55:14

轻量级CRM系统设计:从客户记录到工作流自动化实战

轻量级CRM系统设计:从客户记录到工作流自动化实战 2. 整体设计思路从“记录客户”到“组织客户工作流”先说结论DeskcommCRM 不是一个传统意义上的“客户信息登记本”而是一个把“客户沟通”“跟进计划”“商机推进”“团队协作”全部串在一起的轻量级业务系统。名字本身就是这个项目的定位——Desk 代表每个业务人员的工作台Comm 是 Communication 的缩写指沟通与协作合在一起就是“在同一个桌面上完成所有客户沟通相关工作”。这个定位是在做需求调研阶段定下来的当时团队里每天花在客户信息同步上的时间非常多销售、客服、实施几个角色各记各的账客户问一句“之前谁联系过我”要翻三个人的聊天记录和 Excel 才能拼出答案。所以这个系统要解决的核心问题不是“把客户资料存起来”而是“把围绕客户的每一次沟通、每一个任务、每一个决策串成一条可回溯的时间线”。2.1 为什么不自研工作流引擎而是“轻量自研规则配置”第一版设计时团队里有人提议引入现成的 BPM 工作流引擎理由是市面上已有成熟的流程设计器可以可视化编排“销售录入-分配-跟进-转化”的流程。调研之后我放弃了这条路线原因有两方面一是引入 BPM 引擎意味着要再维护一套流程定义和一套运行时服务对于当时只有三名后端开发、两名前端开发的小团队来说学习成本和运维成本都偏高二是 CRM 的流程形态和 OA 审批流不同客户跟进链路不是严格意义上的“审批链”而更像“状态机触发动作”比如客户从“潜在客户”变成“跟进中”要自动给销售创建一条待办这个用脚本写在业务层反而更直接。所以最终方案是用状态机管理客户生命周期用“动作钩子”触发跨模块操作用数据库事务保证一致性。说白了就是流程简单到可以用代码写清楚就不额外引入重型框架。如果后续业务流程复杂度真的上去了再考虑抽象出独立的流程引擎也不迟系统边界不能一开始就建得过大。2.2 技术选型为什么是前后端分离桌面端外壳项目在技术选型上对比过三个方向纯 Web 端、浏览器插件、桌面应用。纯 Web 端胜在部署方便、不用考虑客户端更新但缺点也很明显——一线业务人员习惯同时开着好几个浏览器标签页处理事情CRM 页面很容易被随手关掉跟进记录和客户资料的窗口经常找不到浏览器插件可以解决一部分“快速录入”的需求但受限于浏览器安全模型无法做全局快捷键、无法常驻系统托盘体验还是不够“重”。综合权衡后我决定采用“Web 后端 Web 前端 Electron 桌面壳”的混合架构后端和前端主体仍然是标准的 Web 应用开发调试效率高Electron 壳负责提供桌面端入口、系统托盘、全局快捷键和本地数据缓存让业务人员打开电脑就能看到待办提醒不用管浏览器地址栏。这种方案的另一个好处是桌面端和 Web 端共用同一套 API 和数据模型不存在两套逻辑各写一遍的问题。Electron 壳里跑的其实就是打包好的前端静态文件本地缓存则是通过 SQLite 同步常用客户数据和操作日志后面会单独讲离线同步的设计。2.3 数据库选型PostgreSQL 还是 MySQL主数据库我用的是 PostgreSQL原因有三个一是 JSONB 字段很适合保存客户扩展属性不同行业的客户字段差异很大不可能都订死成固定列二是自带的数组类型和全文检索能力可以少引入一个 Elasticsearch 之类的搜索引擎三是 Postgres 的事物隔离级别和锁机制在处理并发更新时比 MySQL 默认配置更稳妥一些。对于这个项目的数据量级别——客户数在十万级、跟进记录在百万级——PostgreSQL 完全够用没必要为了可扩展性去引入分布式中间件项目初期最怕的不是“撑不住”而是“过度设计到根本没法快速迭代”。3. 核心功能模块与数据模型拆解模块设计上我把系统拆成了五个核心域客户域Account、联系人域Contact、跟进域Activity、商机域Opportunity、任务域Task外加一个横切所有域的用户与权限体系。下面逐个讲清楚每个模块的字段设计逻辑和业务含义。3.1 客户档案不再只存“公司名电话”客户表是整个系统的基础。第一版设计很简单只有公司名称、联系电话、地址、来源这几个字段但实际用了一个月就发现不够同一家公司可能既是被跟进的目标也是已成交后的服务对象还可能是合作伙伴单一“客户类型”字段表达不了这种多维度关系销售在电话里聊到的需求要点、客户行业、规模、决策链成员等信息如果没地方记录换个人跟进就等于从零开始。所以最终客户表核心字段这样设计CREATE TABLE account ( id BIGSERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT lead, stage VARCHAR(20) NOT NULL DEFAULT new, source VARCHAR(50), owner_id BIGINT, created_by BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), ext JSONB NOT NULL DEFAULT {}, is_deleted BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMPTZ );这里面有几个字段值得展开说。status表示客户当前所处的大状态对应自然语言就是“潜在客户、跟进中客户、成交客户、流失客户”stage表示处在当前状态下的细粒度阶段比如跟进中客户还能具体到“初步沟通、需求确认、方案报价、商务谈判”四个阶段。这样设计的好处是业务上统计“转化率”时看status的变化就够了销售主管想知道“卡在商务谈判环节的客户有多少”直接按stage聚合不用写复杂的业务判断逻辑。owner_id是客户的负责人也就是当前主要跟进人这个字段会在权限校验和任务分配时反复用到。extJSONB 字段用来存不同行业客户的差异化属性。比如做软件销售时客户规模、在用系统、采购流程等这些信息每个行业都不太一样硬塞到主表字段里会非常臃肿放到 JSONB 里既灵活又能保留索引能力。PostgreSQL 对 JSONB 内部字段加 GIN 索引后查询ext {industry: 软件}这样的条件速度完全能接受。3.2 联系人决策链信息比联系方式更值钱联系人表的设计重点不是“存姓名和电话”而是“表达这个人在客户组织中的角色和影响力”。我见过太多 CRM 把联系人字段做成了通讯录既没有职位层级信息也没有“关键决策人”标记销售离职的时候交接清单里写一句“王总是关键决策人”下一个人根本不知道王总的联系方式在哪。联系人表我最终保留了这些关键字段姓名、职位、电话、微信、邮箱、是否为关键决策人、影响力等级、与客户的关系备注、所属客户 ID、创建人、创建时间。其中“影响力等级”是一个枚举值高/中/低结合“关键决策人”布尔值销售在跟进时就能快速判断“我应该重点攻克谁”系统也可以在计划表上自动把关键联系人的沟通任务标记成高优先级。3.3 跟进记录一笔“业务流水账”怎么设计才能不流于形式跟进记录是 CRM 里数据量最大的表也是最能体现系统价值的模块。很多 CRM 的跟进记录就是一个文本字段“今天给客户打了电话客户表示需要再考虑一下”这种记录除了让系统里有点内容之外没有任何分析价值。我设计的跟进记录表强制区分了两层信息跟进方式和跟进结果。CREATE TABLE activity ( id BIGSERIAL PRIMARY KEY, account_id BIGINT NOT NULL REFERENCES account(id), contact_id BIGINT REFERENCES contact(id), type VARCHAR(20) NOT NULL, direction VARCHAR(10) NOT NULL DEFAULT out, summary TEXT NOT NULL, result VARCHAR(50) NOT NULL, next_action VARCHAR(200), next_action_time TIMESTAMPTZ, owner_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );type区分跟进方式电话、邮件、微信、面谈、线上会议等direction区分主动跟进还是客户主动找过来result记录本次沟通的直接结果有明确意向、暂不考虑、约定下次沟通、已成交、其他。summary是过程描述允许自然语言填写。这样设计最关键的一点是当系统要统计“本周电话沟通了多少钱的商机、其中明确说暂不考虑的有多少”时直接查结构化字段即可不用去解析大段的文本描述。这里还有一个容易被忽略的字段next_action和next_action_time。每次跟进的结束必须对应下一次行动的计划否则跟进记录就断了链条。系统会在next_action_time到来时自动给负责人创建一条任务提醒这是保证销售团队持续跟踪客户的手段——时间一到系统就提醒不会再出现“上次说好这周回访结果忘了”的情况。3.4 商机与任务把“可能性”量化成“金额”商机模块对应的是客户从有意向到最终成交的完整过程。每条商机挂在某个客户下包含预计金额、产品类型、成交概率、预期成交日期、当前阶段等字段。商机表的阶段和客户表的stage是不同的概念客户表阶段描述的是客户整体关系进度商机表阶段描述的是“这笔买卖谈到哪一步了”。任务模块负责执行层面的管理。每条任务必须关联到具体客户、商机或联系人且必须设置截止时间。为了避免任务被创建后无人处理任务表也有一个状态机未开始、进行中、已完成、已过期。系统每天定时扫描所有“未完成且已过期”的任务给责任人和他的主管发送提醒邮件。这个看似简单的功能实际对销售团队的执行力提升非常明显——不是靠人肉催而是让系统自己提醒。3.5 权限模型三套权限叠加保数据不串客户数据是公司资产权限做不到位系统就是灾难现场。这个项目采用“角色权限数据范围字段权限”三层叠加的模型。角色权限比较容易理解管理员、销售主管、销售人员、客服专员、只读访客每一种角色对应不同的操作许可比如销售只能新建客户、编辑自己名下客户、查看自己被分配的商机主管可以看到整个团队的客户列表并做重新分配管理员可以做系统配置和用户管理。数据范围是权限的核心难点。我采用的方案是每个业务用户属于一个或多个团队这里的“团队”在销服体系里通常是区域或事业部客户归属于某个团队或某个具体人员规则是——管理员可查看和操作所有数据。主管可查看自己团队的数据可跨团队成员分配客户。普通销售仅可查看自己名下客户与自己参与跟进过的客户未分配的公共客户池可以浏览但不允许直接编辑。只读访客只能查看分配给自己的报表和数据看板不能录入或修改任何业务数据。字段权限是第三层主要控制敏感信息的可见性。比如客户的成交金额、成本信息普通销售只能看到自己负责的客户的金额主管可以看到团队汇总金额只有管理员和财务角色能看到所有客户的利润相关字段。字段权限在前后端都要做控制——“前端不显示接口不返回数据库层面视图过滤”三道防线避免懂技术的人绕过页面直接调接口。实际操作中这三个层级的代码实现分别在三个地方角色权限在 Spring Security 自定义注解里统一拦截数据范围用 MyBatis 的拦截器在 SQL 层自动追加过滤条件字段权限在接口返回前用 Jackson 序列化器过滤敏感字段。这样三个逻辑彼此独立改一个不影响另外两个排查问题也方便。4. 关键技术实现从接口到底层逻辑的完整落地这章挑几个核心场景讲透包括客户分配、跟进时间线、自动任务生成、搜索与报表。4.1 客户分配的两种模式与并发控制客户分配分为“手动分配”和“规则自动分配”。手动分配很简单有权限的销售主管在客户列表勾选客户选择新的负责人系统更新account.owner_id并写入一条操作日志。自动分配则按“未分配客户池”来运作——新录入且未指定负责人的客户统一进入公共池主管可以在公池上配置分配规则比如“按当前团队成员的待处理客户数量最少优先分配”或“轮流均分”。自动分配最大的坑是并发问题。如果两位销售同时点击“领取”同一个客户后端逻辑如果没有做并发控制就可能出现两个人都认为自己成功领到了这个客户最终owner_id被后提交的人覆盖前一个人的会话页面还停留在“已领取”状态。解决办法是在领取接口上做一个条件更新UPDATE account SET owner_id #{currentUserId}, updated_at now() WHERE id #{accountId} AND owner_id IS NULL;执行完再查一下受影响行数如果为 0 说明客户已经被别人领走了接口直接返回“客户已被其他同事领取”。这样在数据库层面就解决了并发问题不需要引入分布式锁。如果某天团队规模变大、单行更新竞争太激烈再考虑改用 Redis 分布式锁或者乐观锁版本号方案也不迟。4.2 跟进时间线一次展示客户全貌客户详情页最核心的组件是“时间线”。设计目标是让任意一个新接手的人打开客户详情页面后能在十秒内搞清楚这个客户是怎么来的、之前谁跟进过、聊了些什么、下一步计划是什么、卡在哪个阶段。时间线的数据来源主要从三个表查跟进记录表Activity、操作日志表OperationLog、商机变化表OpportunityHistory。其中操作日志表记录的是谁在什么时间把客户状态从 A 改到了 B接口返回后按时间倒序合并渲染成一条连续的时间轴。这个时间线不能做成等用户打开页面才全量加载——老客户的跟进记录可能有几百条一次性全查全传会拖慢首屏。我采用了“按月份分段加载”的策略首次加载最近三个月的记录用户滚动到底部时再按月份范围向后加载更多。接口设计为按时间范围分页返回的数据格式统一成{time, actionType, operatorId, content}这种结构前端拿到后直接按模板渲染不用在浏览器里做复杂的多表关联。4.3 自动任务生成与“今日待办”看板自动任务生成依赖于业务规则配置。比如客户从“潜在客户”状态转为“跟进中”时系统自动给负责人创建一条“三日内完成首次深度需求沟通”的任务客户超过 15 天没有任何跟进记录时系统自动创建“客户失联预警跟进”任务并通知主管商机阶段停滞超过 7 天未推进也给负责人生成提醒任务。这里要特别说一下实现的时机问题。任务生成可以在 API 层做比如在更新客户状态的 Service 方法里调用任务创建逻辑也可以在数据库触发器里做。我选择放在业务 Service 层原因是可以自由地在同一个事务里同时更新客户状态和创建任务逻辑看得见摸得着调试时只需要看 Java 代码不用查数据库里的触发器定义。唯一需要注意的是同一事务里写两个表要确认数据库连接配置的事务隔离级别是默认的READ_COMMITTED就好不要为了“保险”改成SERIALIZABLE那会让并发性能下降得非常明显。“今日待办”看板是每个业务人员登录系统后看到的第一个页面。它的数据查询按状态和时间过滤任务表逻辑不复杂但必须加索引。实际投产时我发现待办查询经常超过 500ms查看执行计划后发现问题出在task表上只有主键索引查询条件owner_id status due_time无法命中。加了一个联合索引后查询时间降到 30ms 左右CREATE INDEX idx_task_owner_status_due ON task(owner_id, status, due_time);这就是一个非常典型的“功能开发时没想清楚查询模式、上线后通过慢查询日志才发现索引缺失”的案例后面还会再聊一次索引设计。4.4 搜索模块不用 Elasticsearch 也能做好模糊搜索客户搜索是使用频率最高的功能。业务人员要能按客户名称、联系人姓名、电话、邮箱等多字段混合搜索。第一版我用的是 PostgreSQL 的 ILIKE 查询在十万级数据量下只要没有前端节流连续输入时数据库压力会很大。后期我把搜索改成基于pg_trgm扩展的模糊匹配CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_account_name_trgm ON account USING gin (name gin_trgm_ops);配合 pg_trgm 之后WHERE name ILIKE %关键词%这种查询也能用上索引在数据量不大时完全不输给 Elasticsearch 的效果。联系人搜索时则用了一个轻量方案把联系人的姓名、电话拼接成一个search_text字段上面建 GIN 索引查询时用websearch_to_tsquery做全文检索。每次搜索都要做一次全过程扫描也是不必要的。我加了简单的热门搜索词缓存和结果缓存同一个用户 30 秒内用相同关键词重复搜索直接返回上一次的结果不再打数据库。这个缓存的实现很简单——一条 Redis 字符串加上过期时间而已但对用户体感的提升非常明显。4.5 数据看板主管关心的不是“客户数量”而是“漏斗转化”数据看板是给销售主管和管理层用的核心就是商机漏斗和团队业绩统计。实现上有两种方式一种是在前端调用接口时实时统计另一种是每天晚上定时跑批把统计结果落到一张汇总表里供查询。优劣势需要仔细权衡。实时统计的好处是数据永远是最新的但坏处是每打开一次看板页面数据库就要扫描可能数十万条记录做聚合多个人同时点开看板数据库 CPU 必然告警。定时跑批的好处是查询固定、性能稳定但坏处是数据有最长一天延迟。最终我采用折中方案日常看板从汇总表读数据汇总表每小时跑一次增量更新只有点击“实时刷新”按钮时才临时触发一次实时统计并且这个实时统计结果缓存 5 分钟。这样既保证了常见场景的数据时效性又不会把数据库跑挂。报表设计的核心指标要提前想清楚不能等主管提需求了才开始写 SQL。在项目设计阶段我就和销售负责人对齐了核心指标定义新客户数本周公池新增客户数量、有效跟进数有沟通结果的跟进记录数、商机转化率商机阶段推进到成交的比例、平均成交周期从创建客户到首笔成交的平均天数。指标定义确认后又设计了对应的数据表结构再开始写跑批逻辑否则字段口径不清到时候前后端联调要做大量返工。5. Electron 桌面端、离线缓存与数据同步实战5.1 为什么非要一个“桌面壳”前面提到业务人员如果每天要开着多个系统工作纯浏览器页面并不友好。我在实际调研中发现很多人记不清 CRM 的网址经常被其他标签页吸引走甚至关掉浏览器后就没有 CRM 了。给系统加一个桌面壳之后至少在“存在感”上是完全不同的开机自启、系统托盘常驻、新任务弹通知——这些动作让 CRM 从“一个需要主动打开的网站”变成了“像微信一样一直在那里”的工具。技术实现上Electron 壳没有做太复杂的事就是加载前端构建好的静态文件封装一层外壳加上系统托盘、全局快捷键、消息通知。前端代码不需要感知自己跑在 Electron 里还是浏览器里——通过window.desktopBridge是否存在来判断存在就启用桌面端专属能力否则走 Web 端逻辑。5.2 离线缓存方案SQLite 存什么桌面端离线缓存的价值在于网络不稳定或者出差途中也能查看客户的已有信息和写下跟进纪要。Electron 的主进程里内置了一个 SQLite 数据库定期从服务端同步当前登录人的业务数据自己名下的客户基本信息、最近三个月的跟进记录、今日待办任务、常用联系人。这些缓存的粒度是“当前用户自己的数据”既符合权限规则也能控制数据量——一个普通销售名下几千个客户、几千条跟进记录SQLite 完全扛得住。同步策略是启动时全量校验一次之后每 5 分钟做增量拉取用服务端数据表的updated_at字段做增量判断只同步变更部分。5.3 离线写入与冲突处理离线状态下用户也能录入新的跟进记录或者修改客户阶段这些操作先写本地 SQLite并打上“待同步”标记数据表里额外存一个sync_status字段。网络恢复后主进程按时间顺序将待同步记录推送到服务端。冲突主要集中在同一个客户被两个设备同时修改。比如业务人员在电脑上把客户阶段从“跟进中”改到“报价”他的手机离线端也在同一客户上新增了一条跟进记录。两条操作并发时解决方案是这样的所有写操作带上client_modified_at客户端修改时间。服务端更新数据时对比本次提交的client_modified_at与数据库里的updated_at如果数据库里的更新更晚说明发生冲突。冲突时采用“后写覆盖生成历史记录”策略本次写入仍然生效但系统自动在变更历史中记录一次冲突覆盖动作同时把被覆盖的内容保存下来。如果业务上该字段不允许后写覆盖如已成交客户的成交金额则直接拒绝写入返回冲突提示由用户决定是否强制执行。这套机制不算复杂但赢在可回溯——每次冲突都有记录不搞静默覆盖出了问题能追查。实际上线后冲突事件发生的频率比预期低很多大多数用户每天只用一台设备同时用手机和电脑操作同一个客户的比例其实不高。5.4 Electron 打包与自动更新桌面端打包用的是 electron-builder打包成 Windows NSIS 安装包和 macOS DMG。容易踩坑的是 Windows 下签名问题——没有代码签名证书时SmartScreen 会拦截安装体验非常差。如果团队暂时没有购买证书至少要保证安装包可以从公司内部下载并在用户协议中明确说明首次运行需要点击“更多信息-仍要运行”。自动更新用 electron-updater发布配置指向内网文件服务器或者对象存储的发布目录。更新策略是启动时后台检查新版本发现后下载安装准备就绪后弹窗提示用户重启即可。要注意的是自动更新的发布版本号必须严格递增且打包时必须带上latest.yml文件——electron-updater依赖它做版本校验少这个文件会导致永远检测不到新版本这个坑我踩过折腾了半小时才发现是发布目录少传了文件。6. 数据一致性与性能优化实践6.1 一个插入顺序引发的“事务边界”问题系统里最容易被忽略的坑出现在“新增客户并创建首次跟进任务”这个操作上。按业务要求新建客户时如果来源是“转介绍”系统要自动给负责人创建一个“回访转介绍人”的任务。第一版代码是在客户插入成功后就去查当前任务表里是否已经存在同一个转介绍人的任务没有才创建——这本身没问题但代码写在两个不同的 Service 方法里第一个方法提交了事务第二个方法才去查询任务表。这个顺序会带来一个隐蔽的 bug如果第一个方法事务提交成功、第二个方法创建任务失败整个请求返回异常给前端用户以为新增客户失败再点一次提交结果客户在数据库里就存在了两条。排查到最后方案很简单把两个操作放到同一个事务方法里先插客户、再插任务要么都成功要么都回滚。这个经验非常有代表性——对于需要保证数据一致性的场景绝不能依赖两个独立事务“碰巧都能成功”必须显式控制事务边界。6.2 时区问题的坑TIMESTAMPTZ 不是万能药数据库字段全部用了TIMESTAMPTZ本意是统一存储 UTC 时间避免不同时区的用户看到的日期不一致。但实际使用中问题出在“展示”而非“存储”服务端返回时间的序列化方式没有统一有时返回 ISO 字符串、有时返回时间戳前端对不同格式处理方式不同导致同一台手机和电脑上看到的“下次跟进时间”差了 8 个小时。最后的统一规范是API 层一律返回 ISO 8601 格式字符串并带时区偏移如2025-06-15T10:30:0008:00前端拿到后统一转成本地时间显示。绝对不要在服务端转成“不带时区的本地时间字符串”再返回——这是最容易被误解的地方也是时区 bug 的经典来源。6.3 前端性能列表页卡顿的元凶客户列表页初期在大数据量下明显卡顿问题不在后端接口接口已做分页而是前端把当前页所有客户的“最近联系时间和最近联系摘要”全部渲染出来。一个页面显示几十个客户每个客户拉起一个子查询显示最近一条活动记录这就变成经典的 N1 查询问题了。解决方式是后端在列表接口里一次性 JOIN 出最近一条跟进记录直接在 SQL 里做掉SELECT a.*, ac.summary AS last_activity_summary, ac.created_at AS last_activity_time FROM account a LEFT JOIN LATERAL ( SELECT summary, created_at FROM activity WHERE account_id a.id ORDER BY created_at DESC LIMIT 1 ) ac ON TRUE WHERE a.owner_id #{currentUserId} ORDER BY a.updated_at DESC LIMIT #{pageSize} OFFSET #{offset};LEFT JOIN LATERAL在这些场景中性能非常好——对每一行客户记录只执行一次子查询取最近一条活动避免了大范围扫描同时也不会造成笛卡尔积膨胀。列表页从打开要 3 秒降到了 300ms 以内。6.4 全文搜索和报表的索引设计复盘项目上线一个月后我把所有核心查询的慢日志都翻了一遍整理出几个需要加索引的场景。首当其冲的是账期报表的created_at过滤最初没加索引前按月统计客户数要扫全表后来加了idx_account_created_at之后好很多。其次是商机表的opp_account_id stage联合查询多渠道搜索时也会走全表扫描后来加了联合索引idx_opp_account_stage。常见的经验是索引不是越多越好而是要根据真实业务查询模式来建加索引前一定要用实际业务语句跑一次EXPLAIN ANALYZE看执行计划不要凭感觉猜。需要注意的是JSONB 字段的索引设计比较特殊。如果要在业务上过滤ext-industry这样的条件需要建 GIN 索引或者给该字段建表达索引。我实际生产中主要用 GIN 索引因为行业和客户规模这类属性查询是多值的GIN 比 B-Tree 更合适。7. 常见问题与排查技巧实录7.1 “客户被创建了两条”为什么查不到代码问题这个现象在用户那边表现为销售提交新建客户后报错刷新后发现客户在列表里出现了两条几乎一样的数据。第一反应是检查“新建客户”接口有没有做防重但代码看起来一切正常——每创建前都会先按客户名称和联系电话查重存在就返回重复提示。百思不得其解时我去查了服务端日志发现问题出在“接口超时重试”上。网关层配置了超时重试机制当服务处理超过 3 秒网关自动重发请求但第一次请求其实已经执行成功只是响应超时第二次请求再进来时由于第一次事务还未提交正在等待其他操作查重查询没有查到未提交数据于是又创建了一条新客户。这个问题的根治方式是在“新建客户”接口增加一个基于client_request_id的幂等控制。前端每次提交时生成一个 UUID 放在请求头里服务端在处理写操作前先查询request_log表中是否已存在相同request_id存在就直接返回第一次的结果不存在才继续执行。这样无论网关重试还是用户手抖多点几次都不会造成重复创建。7.2 “任务不见了”其实是状态机跳过了一个比较难排查的问题是用户反馈“昨天创建的客户跟进任务今天不见了”。初步看任务查询逻辑和状态机逻辑都正确但打开数据库一查发现任务的状态确实已经是“已完成”而用户表示自己没有操作过。后来查看操作日志才明白业务审批流里有一个自动动作——当客户被标记为“无效客户”时系统会未完成的任务自动判定为“已完成”同时释放任务提醒。这个逻辑本意是好的避免给已经无效的客户继续推送跟进提醒但当时设计这个规则时没有确认业务口径导致所有还在跟进中的商机只要被暂时标记为“暂缓”所有关联任务就全部消失了。这个案例说明在 CRM 业务系统里“任务状态自动变更”这类规则必须和业务团队反复确认不能让技术同事单方面决定业务规则否则做一些看似合理的自动化逻辑会误伤正常流程。7.3 “明明有权限却看不到数据”的字段权限陷阱一个用户反馈他是销售主管能看到团队成员名单但打开团队某个成员的客户列表时数据始终显示为空。排查接口权限时发现数据范围过滤条件里写了“主管只能查看自己团队的数据”但对“团队”的判断逻辑是取用户的team_id而该成员所在团队还有二级子团队数据挂在了子团队下面主管的team_id与子团队 ID 不匹配所以查不出来。这个问题在添加子团队功能后容易出现。权限查询不能只判断当前用户的直属团队还要递归判断所有下级团队。代码里用一个递归 CTE 或者应用层递归收集所有子团队 ID 后再拼进 IN 条件即可WITH RECURSIVE team_tree AS ( SELECT id FROM team WHERE id #{currentTeamId} UNION ALL SELECT t.id FROM team t JOIN team_tree tt ON t.parent_id tt.id ) SELECT * FROM account WHERE team_id IN (SELECT id FROM team_tree);这个经验对权限体系的设计有普适意义只要有层级组织架构所有数据范围的权限判断都必须考虑“是否包含下级”不能默认用户只有一个团队层级。7.4 离线同步把数据“同步丢了”怎么办Electron 端离线写入存在 SQLite 里的记录网络恢复后同步到服务端理论上不会有数据丢失但极少数用户反馈“写了记录但同步后服务端没有”。后来查下来发现原因是本地 SQLite 表中的自增主键与服务端 ID 没有任何对应关系同步逻辑里根据本地主键查找服务端记录时服务端根本不存在该 ID导致更新被忽略。解决方式是引入本地client_guid字段每次创建记录时生成一个全局唯一的字符串同步时用这个字段到服务端查找“该记录是否已存在”存在则更新、不存在则新增。服务端给这个字段加了唯一索引这样即使同一记录被重复推送也只会被处理一次不会产生重复数据。7.5 常见问题速查表现象可能原因排查优先级推荐方案客户列表查询慢缺少联合索引高检查慢查询日志按查询条件建复合索引新建重复客户网关超时重试或用户重复提交中接口幂等控制用 request_id 去重任务被自动完成状态机自动跳转规则配置不当中查看操作日志还原变更来源主管看不到成员数据组织架构层级维度未处理高权限查询用递归 CTE 包含子团队离线记录同步后丢失本地主键与业务 ID 不一致中引入全局唯一 client_guid服务端加唯一索引桌面端消息不弹Electron 通知权限未开启或打包时没配置高检查系统通知设置与应用权限配置看板数字对不上跑批任务失败或汇总表延迟中记录跑批日志增加重跑机制和脏数据标记8. 上线部署与后续演进方向8.1 部署架构一台 4 核 8G 服务器能跑吗初期团队规模小我选择先用一台 4 核 8G 的云主机部署整套系统。架构上是传统单体Nginx 托管前端静态文件并反向代理后端 API后端是 Spring Boot 单体应用数据库用同一台机器上的 PostgreSQL另外单独跑一个 Redis 实例做缓存和 Session 存储。这套方案在几十个活跃用户、十万客户数据量下完全够用。如果用户量增长第一件要做的是把 PostgreSQL 和 Redis 拆到独立机器上避免应用和数据库抢资源。再往后才考虑后端横向扩容、静态资源上 CDN 等方案。规模化演进的前提是先确认瓶颈在哪个环节不要一开始就上 K8s 加微服务那只会把问题和成本一起放大。8.2 监控与告警第一时间发现问题系统上线后第二周我就接入了简单的监控告警。核心监控指标有四类API 请求错误率超过 2% 就告警接口 P95 响应时间超过 2 秒就告警数据库慢查询数量超过阈值就告警关键定时任务数据同步、自动任务扫描执行失败就告警。告警渠道用的是企业微信机器人把 Webhook 怼到运维群里。这个配置很简单但实际价值很大有好几次自动任务凌晨执行失败如果没有监控第二天用户会集体发现待办任务没生成那是非常严重的业务事故。有了告警运维在第二天上班前就发现问题并手动重跑解决了。8.3 后续演进方向AI 辅助跟进摘要项目稳定运行后我目前已经在规划下一阶段的演进方向比较明确的是“AI 辅助跟进摘要”这个功能。思路是当销售录入跟进记录时系统通过调用大模型接口自动从对话文本中提取客户的核心需求、决策人态度、风险点等结构化信息填入通信息字段中减少人工归类成本。另外也可以根据历史跟进内容自动生成建议的下一步行动计划辅助销售做跟进判断。这个功能的技术难点不在模型本身而在于数据隐私和成本控制跟进记录属于客户敏感信息调用外部模型接口前必须做脱敏处理大模型 API 的成本也要控制在合理范围不能每次保存都调一次。实际落地时可以先做“手动触发”而不是“自动全量”——销售保存跟进记录时可以选择“生成智能摘要”按钮这样一个客户一天最多触发一两次成本可控。9. 写在最后的实操体会做 DeskcommCRM 这个项目的最大感受是这类业务系统的技术难点从来不在某个高深算法或者复杂中间件上而在于如何把混乱的线下业务流程抽象成清晰的数据模型并且让使用者觉得“系统在帮我干活而不是给我添麻烦”。所以在设计每一个字段、每一个状态、每一条自动规则的时候都要反复问一句“这个设计是真正解决用户的问题还是只是让系统看起来更完善”如果让我重新做一遍这个项目我会在需求阶段多花一倍时间在业务流程访谈上特别是搞清楚销售团队日常操作习惯而不是关起门来套用通用 CRM 的功能模板。很多上线后才发现要改的功能本质上都是需求没有对齐就动手开发导致的返工。另外一个特别值得推荐的实操习惯是每次上线前把核心链路从头到尾手动跑一遍用真实数据而不是测试数据。你别小看这一步很多看似不起眼的小问题比如自动建单逻辑没考虑空商机、联系人电话格式校验太死、字段显示逻辑没兼容老数据都会在手动走真实流程时暴露出来。最后分享一个踩过的坑没有从一开始就建立操作日志审计表。前期觉得“反正内部用不需要记录谁改了什么”结果后面出了几次数据问题想追溯变更来源时发现查无可查。后来补上了全量的操作日志模块这个模块看起来平时没什么存在感但一旦出了问题它就是你的第一道也是最重要的一道排查线索。做任何业务系统操作日志都不要省。