☰
自研CRM系统从0到1落地复盘:数据建模、权限设计与性能优化实践
2026/10/19 5:11:04 网站建设 项目流程

在盘点团队内部工具链的时候,我发现一个非常讽刺的现象:销售和客服同事每天产生的大量高价值信息,并不在我们采购的ERP里,而是分散在聊天软件、邮件客户端、Excel表格和个人备注中。客户上周说过什么、谁承诺了什么时候回访、报价发出去之后有没有下文,这些问题的答案全凭人的记忆。这种状况持续了小半年,直到我们决定动手做一个属于自己的客户关系管理系统,也就是后来团队内部一直在用的 DeskcommCRM。这篇文章是对整个项目从0到1落地的完整复盘,包含数据建模、权限设计、自动化任务、以及一次把我从周末里拉回来的性能事故。如果你也在评估或计划搭建一个内部CRM系统,应该能从里面找到不少直接能抄走的经验。

DeskcommCRM 这个名字是我起的,Desk + Communication + CRM,意思是"面向桌面办公场景、以沟通为中心的客户关系管理"。它面向的是一支12人规模的销售与客服混编团队,第一版的技术选型是 .NET 8 + Vue 3 + PostgreSQL 12,后台任务用 Hangfire,部署在公司内网。系统本身不大,却踩遍了这类内部系统会遇到的典型问题:数据模型设计、权限边界、自动提醒、列表性能。下面按项目推进的时间线来讲。

1. 为什么一个小团队要自研CRM,而不是直接买现成的

1.1 真实痛点:信息断裂而不是功能缺失

痛点要从一次客户交接说起。有位销售离职,给接手的人留下一个3MB的Excel,里面是他半年来的客户跟进记录,里面混着公司名、人名、电话、只有他自己看得懂的备注。没有聊天上下文,没有邮件记录,也没有下一次跟进时间。新同事接手之后,只能挨个打电话重新寒暄,客户觉得怎么又换人了,团队觉得交接制度有问题,但实际上没有一个系统能承接这些过程数据。

类似的场景每个月都在发生。销售团队日常大量时间花在沟通上,但沟通发生的地方(企业微信、Outlook、电话、线下拜访)和客户信息存放的地方(Excel、老业务系统)是割裂的。我们不需要一个更多功能的CRM,我们需要一个能把"客户档案"和"沟通记录"放在同一个时间线上、并且能自动提醒"该跟进谁"的工具。

1.2 为什么不直接用现成产品

我们不是没评估过采购路线。SaaS类CRM按坐席收费,一百多人的客户数据放在云端,光数据合规和接入审核就要走很长的流程;想要自定义字段和工作流,又动辄需要实施顾问陪跑数周。开源版本的CRM也看过,很多项目把重心放在营销自动化、漏斗报表这些"看起来很强"的能力上,反而最需要的"把一段IM聊天归档进客户时间线"这个基础能力非常弱。

另一个现实原因是,公司内部已经有一套成熟的账号体系和邮件网关API。自研CRM可以直接复用这些基础能力,销售打开系统看到的不再是一套孤立的新软件,而是和办公环境打通的生产力工具。这种"复用内部能力"的价值,在选型对比时容易被低估。

1.3 第一版的范围控制:只做四件事

立项当天我就给自己定了一条规矩:第一版只做四件事——客户档案、沟通记录、任务提醒、基础看板。工作流引擎、审批流、销售漏斗预测、移动端App,全部砍掉。内部工具最怕一上来就设计一个无所不包的平台,结果核心场景都没跑通。CRM的价值从来不在于功能列表有多长,而是信息能不能在关键节点自动流动起来。第一版只要能把"客户发生了一件事,负责销售的页面上立刻出现一条待办"跑顺,这个项目就算成功。

2. 第一版数据模型设计:把"跟进记录"放在核心位置

2.1 核心表结构

第一版涉及的业务表不算多,真正的难点在于哪些表是主表、哪些表围绕谁来组织。团队里当时有两种声音:一种认为客户表是大中心,所有数据挂在客户下面;另一种认为沟通记录才是这个系统的灵魂。实践下来,后一种观点更贴近真实业务。核心表大致如下:

  • users:用户表,对接内部账号系统,包含角色和部门信息
  • customers:客户/公司档案,记录名称、行业、规模、状态和归属人
  • contacts:联系人,一个客户下可挂多个联系人,每个联系人有独立职位和联系方式
  • conversations:沟通记录,一条记录代表一次完整沟通事件
  • conversation_messages:沟通明细,邮件正文、聊天逐条内容、电话录音链接
  • tasks:跟进任务,包含负责人、截止时间、关联客户和状态
  • customer_shares:客户共享关系,记录共享给谁、谁创建、到期时间
  • audit_log:审计日志,记录高风险操作的前后值

这张表结构里,conversations不是customers的子表,而是一张独立实体。这样设计的原因后面单独说。

2.2 为什么沟通记录要独立成表,而不是挂在客户下面

最早的设计稿里,有人提议在customers表上加一个last_follow_up字段,记录最后跟进时间和备注。这个方案写起来最简单,但根本回答不了两个问题:这个客户的跟进过程是什么样的?谁在什么时候说了什么?销售和客服真正的日常是"沿着时间线回看沟通上下文",而不是只看一个孤零零的更新时间。

另外,一次沟通可能同时关联多个对象。一个同事去参加行业展会,聊了三家意向客户,回来之后这通沟通要能同时归到三家客户的记录里。如果把conversations做成customers的从表,这种一对多的场景就表达不了。所以我用了独立表加关联表的结构,conversations与customers、contacts都是多对多关系,业务上需要归属到哪个实体都可以随时挂载。

2.3 自定义字段用JSONB,而不是一上来就上EAV

销售团队对"字段"的需求永远是不满足的。有人要记"客户来源渠道",有人要区分"续费周期",还有人想记录"竞品情况"。如果第一版就按这些需求设计固定列,数据库迁移会把人逼疯。我的做法是在customers表上设计一个meta jsonb字段,用于存放自定义属性。同时约定第一版只支持最多20个自定义字段,避免配置变成新的负担。

PostgreSQL 的 JSONB 配合 GIN 索引,应对这种低频查询完全够用。后来我们需要按meta -> 'source'做筛选,直接建了一个表达式索引。这里有一个非常容易踩的坑:不是所有JSONB字段都值得建索引,一定要先看查询计划是不是真的走到索引,否则等于白占存储空间。

CREATE INDEX idx_customers_meta_source ON customers USING gin ((meta -> 'source'));

2.4 软删除与审计:客户数据不能像废数据一样DELETE

CRM里最敏感的就是客户数据,误删一条记录可能意味着丢了一个潜在合同。所有业务表我都设计了deleted_at字段,删除操作只做标记,不真正物理清除。真正物理删除的时机是在备份验证通过后,由后台任务统一清理。

同时建立了一张audit_log表,记录核心字段的变更。每次更新客户归属、修改敏感字段、调整状态,都会记录操作人、操作时间和变更前后的快照。这个设计在第一版看起来增加了开发量,后来却帮我们避免了一场信任危机,第三章会专门讲。

3. 权限模型与数据可见性:最容易让团队内耗的设计

3.1 第一版犯的错:谁都能看到所有客户

第一版权限做得非常粗:系统管理员可以看所有数据,普通销售只能看自己的客户。听起来没问题,但真正上线一周就暴雷了。

问题出在客户分配逻辑上。当时所有新录入的客户都进入公共池,没有自动归属给具体销售。结果就是谁手快谁先认领,同一个客户可能同时被两三个销售加了备注。最极端的一回,一个意向客户被两个销售分别跟进了两周,最后成交时归属不清,两边都觉得是自己跟出来的。这已经不是技术bug,而是直接造成了团队内耗。权限和归属这种事,在CRM里从来都不是小问题。

3.2 所有权、共享与公海池的模型

第二版重构了底层逻辑。每个客户只有一个owner_id,也就是唯一负责人;只有负责人和其直属主管默认看到这条客户数据。如果需要协作,通过customer_shares表显式共享,共享可以设置到期时间,到期后自动回收权限。这里的设计原则是:默认不可见,共享是例外而不是常态。

公海池则通过customers.status = 0来表达,即未分配客户。系统每天早上跑一次任务,把超过30天未跟进的客户退回公海池,并通知原负责人。这个规则一开始很多人反对,觉得手上的客户怎么能被系统收走。但实际运行两周后,大家反而更积极处理老客户了,因为不跟进就会失去所有权。

3.3 列表查询的权限过滤必须下推到SQL

在实现列表接口时,我给自己定过一条死规矩:永远不要把"用户可见的所有客户"查出来再在内存里过滤。正确的做法是在SQL阶段就加上可见性条件。我封装了一个权限解析函数,入参是当前用户和操作类型,返回一个该用户可见的客户ID集合,再和主查询做JOIN。内部系统数据量通常不大,这个集合也就是几百到几千条,性能压力可以忽略。

这里还要提醒一点:不要试图在Service层到处手写权限判断,那样一定会漏。我们把权限解析放在一个独立的AccessPolicy模块里,所有Controller调用同一个方法,保证"这是管理员可见的""这是主管可见的""这是负责人可见的"逻辑只维护一份。

3.4 查看客户详情也要记录审计日志

我们把"查看客户详情"也列入了审计事件,最初团队觉得这是过度设计,但后来确实派上了大用场。有一次客户投诉某个销售在未授权的情况下查看了他的报价记录,我们直接查audit_log,还原了谁在什么IP、什么时间点、查看过哪些字段。虽然最后发现是客户记错了,但这件事之后,团队对外沟通也更有底气。

审计日志不建议什么都记。如果每次打开列表都记一条,日志表几天就会被撑爆。只记高风险动作:查看敏感字段、导出列表、修改归属、删除记录、批量导入导出。低频、高价值,这才是审计的正确打开方式。

4. 日程提醒与自动化:看似小功能,实际最耗时

4.1 提醒不是一个简单的定时任务

DeskcommCRM 里有一个"要跟进"的页面,本质是任务提醒的聚合:任务到期前30分钟提醒一次,逾期后每小时提醒,只有工作时间提醒,法定节假日顺延。这个需求看起来很简单,但实现起来比预想中复杂得多。我选择了 Hangfire 作为后台任务框架,把每个提醒看成一条可重复调度的Job。创建任务时,根据提醒规则计算下一次执行时间;任务执行时,检查任务当前状态,决定继续调度还是终止。

如果只是一条"每天扫描一次数据库"的任务,代码会简单不少,但提醒的准确性和及时性都会差一截。销售收到一条"客户3天没联系了"的提醒时,如果系统里显示的沟通记录已经是5天前,体验就会很糟。

4.2 工作时间和节假日:比常见认知更细节的坑

这个模块里最容易被忽略的是节假日。我们团队没有海外业务,时区问题不算严重,但"9点到18点"的工作时间必须结合节假日表来实现。我建了一张holidays表,每年初把下一年的法定节假日和补班日期提前录入。提醒调度在计算"下一个工作日"时,要跳过holidays表中的日期,同时还要处理补班日。

这个逻辑本身不难,但如果不把它做成配置,而是硬编码在提醒规则里,未来每一年都要改代码。另外提醒任务要设置最大提醒次数,比如逾期最多提醒10次就自动停止,避免客户成交了之后销售还一直被旧任务的提醒轰炸。

4.3 跟进自动流转:只做两条关键规则

自动化规则第一版我只做了两条:

  • 销售把客户状态改成"跟进中"时,自动创建一个3天后的回访任务
  • 客户超过15天没有新增任何沟通记录时,给负责人发送沉睡预警

实现上并不复杂:第一条靠数据库触发器和应用层事件同步完成,第二条用每个夜晚运行的recurring job扫描conversations表的更新时间即可。有一个经验值得分享:批量扫描任务一定要控制批处理大小,比如每批处理200条,处理完先提交事务再继续下一批。否则一个客户表几万行,其中几千个沉睡客户同时发提醒时,大事务会把数据库锁到让前台查询跟着变慢。

4.4 邮件网关集成:发送之后如何可靠归档

销售在系统里发邮件,背后调用的是公司已有的邮件网关API。这个集成里最重要的不是发送动作,而是投递状态回调。邮件网关收到信之后,会往DeskcommCRM的webhook接口推一条投递结果,系统再把邮件写入conversation_messages表,回填发送时间、收件人、正文存档。

如果只发不归档,那沟通记录里就缺了邮件这一大块,销售和客服的时间线就残缺了。第一版差点想省掉这个步骤,后来发现"给客户发过报价邮件"和"收到客户回复邮件"对于跟进判断实在太关键,无论如何都不能省。

5. 上线三个月后的一次性能事故:列表页慢查询复盘

5.1 现象:打开客户列表要4秒

上线第三个月,陆续有销售反馈"打开客户列表要转很久"。最初我以为是网络问题,后来发现最夸张的时候要4秒多。某个下午,几个销售同时刷新页面,接口直接耗时接近10秒,群里立刻炸锅。性能问题对内部系统的信任损伤比功能bug更大,因为用户会觉得"这个系统不行",之后你再怎么解释优化了都没用。

5.2 排查链路:从浏览器到数据库日志

我在本地完全复现不了这个卡顿,因为本地测试数据只有几百条,查询都是毫秒级。后来意识到线上数据分布和本地完全不同——线上客户表有3.7万行,沟通记录有28万行,这个量级正好会触发一系列问题。

排查过程是这样的:先打开浏览器开发者工具,确认慢的接口是"客户列表";然后开 PostgreSQL 的慢查询日志,找到执行时间超过1秒的SQL;逐条EXPLAIN ANALYZE之后,问题锁定在三个地方:

问题表现根因
N+1查询ORM对每个客户额外查询一次最近沟通记录列表页关联加载,3.7万客户变成几万条SQL
JSONB条件全表扫描按自定义字段筛选时很慢没走表达式索引,PG对整表做扫描
OFFSET深翻页翻到第100页时明显变慢需要扫描前100页所有行

5.3 修复:字段冗余、表达式索引、Keyset分页

修复方案有三步。第一,把"最近沟通时间"和"最近沟通内容摘要"直接冗余到customers表,每次写入conversation记录时同步更新这两个字段。这样列表页不需要实时去关联查询沟通表,页面展示和存储模型彻底解耦。

第二,给JSONB字段建GIN索引,并重写了筛选条件,让查询计划命中索引。

第三,把分页从OFFSET改成keyset分页。后端接口不再接收page,而是接收上一页最大记录的updated_at和id,查询条件改为WHERE (updated_at, id) < (?, ?)。这个改动让深翻页的扫描量从几万行变成几十行,效果立竿见影。

修复之后,最慢的接口从4秒降到了280毫秒,销售再也没抱怨过列表卡顿。这次事故让我在后来的内部系统开发里养成了一个习惯:但凡列表页接口,在上线前必须用接近线上的数据量做压测,而且必须能在日志里看到ORM实际生成的SQL数量。

5.4 如何用代码评审防止再犯

项目组后来把一条规则写进了代码评审清单:所有列表页接口禁止直接返回ORM实体,必须显式映射到DTO;所有涉及关联查询的地方,必须确认不是N+1。我们还配置了数据库查询分析工具,只要单个请求生成的SQL数量超过50条,自动告警。

内部系统虽然没有互联网级的高并发,但一个糟糕的查询足以让整个系统体验归零。性能优化往往不是加分项,而是及格线。

6. 一些不写进文档的落地经验

6.1 让销售团队参与验收,而不是只看PPT演示

这个项目能活下来,一半靠的是从一开始就让销售主管参与验收。他们不关心技术架构,却会在验收时提出特别具体的问题:"这个标签能不能批量改""列表默认按跟进时间还是创建时间排""客户详情页能不能直接发消息"。这些反馈看起来琐碎,其实才是系统真正的价值点。内部工具能不能用起来,往往不取决于功能强不强,而取决于这些细小的交互是否贴合真实工作习惯。

6.2 数据导入是最容易翻车的地方

系统上线前要把Excel里的历史客户数据导进去。这里千万别想着写一个通用导入器。现实是:同一个客户在Excel里可能有多行,邮箱大小写不一致,公司简称和全称混用,还有些客户根本没有联系人手机号。我们花了一周时间写清洗脚本,跑完之后还要人工确认去重规则。如果当初草率导入,后面会出现大量重复客户,销售一旦看到一堆重复数据,就会对这个系统彻底失去信心。

6.3 每天早起五分钟看日志

半年下来,我养成的一个习惯是每天花五分钟看前一晚的慢查询日志和错误日志。内部系统用户少,报错量不高,但日志里经常暴露一些数据边界问题,比如某个客户没有owner_id、某个状态字段出现了空值、某个定时任务被异常跳过。坚持看了半年,很多问题都在用户发现之前被提前处理掉了。比起写更多功能,日常维护的这些小事反而更影响内部系统在同事心里的口碑。

如果重新做一次DeskcommCRM,我会继续保持"少就是多"的克制,但在数据导入和权限模型上会多花一倍时间。这两个部分不像功能页面那么有成就感,但它们是用户信任的根基。技术方案上,.NET 8 + PostgreSQL 这个组合对内部管理系统来说非常稳,没有必要为了分布式而分布式——真正的复杂度从来不在框架里,而在业务规则的边界里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询