进入CRM这个领域,说起来有点偶然。之前我们团队一直用的是某款通用CRM,功能确实全面,但越用越别扭——销售、客服、实施三个部门各自为政,客户信息在不同系统里来回倒腾,数据口径经常对不上。后来客户投诉多了,老板一拍桌子说干脆自己搞一套。于是就有了 DeskcommCRM 这个项目,从立项到落地差不多折腾了半年。这篇文章把我的完整思路、技术选型、踩坑经历都整理出来,给准备自研或深度定制CRM的团队做个参考。
1. 立项之初:DeskcommCRM要解决的真实问题
1.1 业务痛点在哪里
先说说我们当时面对的烂摊子。公司业务线分三块:一是标准产品销售,二是定制化项目实施,三是售后维护服务。这三条线的客户信息是重叠的,但管理方式完全割裂——销售记录在A系统,实施过程用Excel跟踪,售后问题散落在群里和邮件里。
最要命的是客户交接。销售离职或者内部换人跟进的时候,新接手的人根本不知道之前聊到哪里了,合同条款、客情关系、承诺过的交付时间全靠猜。还有更头疼的,同一个客户可能同时在下单、在实施、在报修,但三个角色互相看不到对方的进展,导致销售承诺了实施排期根本排不进来,客服答应的问题实施侧压根不知道,客户体验自然一塌糊涂。
所以我立项时定的第一个原则是:DeskcommCRM不是要把所有流程都管起来,而是要先把客户全生命周期这条主线打通。客户从线索到成交、到交付、到售后,所有关键节点的信息必须在一个系统里看得到。
1.2 为什么不自研一套,而是改造开源方案
可能有人会说,你们需求这么明确,直接买个现成的CRM不就行了,为什么非要自己搞?
我当时算了一笔账。市面上的商业化CRM,按坐席收费,我们50个用户一年下来订阅费小几十万,而且定制接口还要额外加钱。更重要的是,很多CRM的核心数据模型是锁死的——比如客户字段、跟进状态、商机阶段,想改就得提需求单排队等排期。
再看开源方案,像SuiteCRM、EspoCRM、Odoo的CRM模块,功能底座是够用的,但二次开发的学习成本和改造工程量也不能小觑。我们最终选了一条中间路线:以开源CRM为底座,把核心业务逻辑重写,外壳重新设计,对外统一叫DeskcommCRM。说白了,借用开源项目的成熟骨架,避免从零开始造轮子,但核心能力全部按自己的业务逻辑来定制。
1.3 项目边界与核心目标确定
立项会上我提了三个核心目标,后面所有工作都围绕这三个目标展开:
- 统一客户视图:全公司任何角色打开一个客户页面,能看到所有相关信息。
- 可配置的销售流程:不同产品线、不同金额等级的商机流程可以自定义,而不是一套流程走到底。
- 内外部协作留痕:所有沟通记录、任务分配、客户反馈必须沉淀在系统里,避免信息停留在个人微信和邮件里。
同时,我明确划了一条线——我们不做财务模块,不做ERP集成。CRM就是CRM,管好客户相关的数据流转就是本分,把手伸太长反而会拖慢项目节奏。这个边界意识在后面帮了大忙,几次需求蔓延都被这条红线挡住了。
2. 架构设计与技术选型背后的取舍
2.1 技术栈选择
DeskcommCRM的技术栈我纠结了很久。原先团队擅长的是PHP,但考虑到后续要做实时消息推送和复杂权限控制,我最终选择了Node.js + React的组合。
后端用的是NestJS,TypeScript带来的类型安全在后期的迭代中省了非常大的精力。前端用React + Ant Design Pro,Ant Design的组件体系做后台管理系统真的很顺手,表格、表单、弹窗这些高频组件开箱即用,省了大量UI开发时间。
数据库选型上,主库用了PostgreSQL,主要看重它在复杂查询上的表现。CRM系统大量的客户画像查询是动态字段的,PostgreSQL的JSONB类型可以很好的应对。比如客户的个性化属性字段,随时可能要加一个“预计采购量”之类的自定义列,JSONB直接存,不需要频繁改表结构。
缓存用了Redis,主要用来做会话管理和热点数据缓存。有一段时间客户列表页很慢,排查下来发现每次打开列表页都去查一次全表统计,后来把这个统计数据在Redis里做5分钟缓存,响应时间从2.8秒降到了400毫秒左右。
2.2 数据模型设计
数据模型是整个CRM的骨架,这块我花的时间最多。核心数据模型有五个:客户(Account)、联系人(Contact)、商机(Opportunity)、跟进记录(Activity)、工单(Ticket)。
这里说几个关键设计。
客户与所有业务对象的关联用了剥洋葱式的层级关系。客户下面有联系人,联系人有沟通记录,客户有商机,商机有跟进日志,客户又有工单,工单涉及产品、解决方案。任何一张主表都能关联到客户,并且通过客户ID拉起这个客户的全景视图。
客户全景视图的SQL关联结构大致是这样:
async getCustomerOverview(customerId: string) { const [account, contacts, opportunities, tickets, activities] = await Promise.all([ this.accountRepo.findOne({ where: { id: customerId } }), this.contactRepo.find({ where: { accountId: customerId } }), this.opportunityRepo.find({ where: { accountId: customerId }, order: { updatedAt: 'DESC' } }), this.ticketRepo.find({ where: { accountId: customerId }, order: { updatedAt: 'DESC' } }), this.activityRepo.find({ where: { accountId: customerId }, order: { createdAt: 'DESC' }, take: 20 }), ]); return { account, contacts, opportunities, tickets, recentActivities: activities }; }自定义字段是另一个重点。不同业务线对客户字段的需求不同,销售关注采购决策链,实施关注技术接口对接人,售后关注服务级别。我在客户表上预留了一个JSONB字段extra_attributes,并且做了一个字段配置表,让管理员可以自行配置表单上的自定义字段映射到这个JSONB上。
2.3 权限体系设计
权限是整个CRM里业务价值最高也最容易做砸的地方。我们一开始就定了基调:数据权限要做到行级控制,而不是页面级控制。
比如销售A和销售B,都能看到同一个客户的页面,但A只能看到自己创建的商机,B只能看到自己负责的工单。共享客户的时候,可以设置共享人的查看权限是只读还是可编辑。
实现行级权限用的是PostgreSQL的Row Level Security,在数据库层面就做了行过滤,而不是纯粹依赖应用层过滤。这样无论如何通过API查询,数据都是安全的,不会因为某个查询接口忘了加where条件就导致越权。
一段RLS策略示例:
CREATE POLICY sales_opportunity_access ON opportunity USING ( owner_id = current_setting('app.current_user_id')::uuid OR id IN ( SELECT opportunity_id FROM opportunity_share WHERE shared_with_id = current_setting('app.current_user_id')::uuid ) );有了这层策略,应用层完全不需要重复写条件,代码简洁了很多,而且权限逻辑集中管理,不容易漏。
3. 核心功能模块的落地细节
3.1 客户信息管理的设计思路
客户信息管理这个模块看起来简单,但做起来细节非常多。首先是客户查重。业务员录新客户的时候,如果系统里已经有这个公司了,再录一遍就是数据垃圾。我们做了企业名称的相似度匹配,用的算法是编辑距离加拼音首字母组合匹配。
查重逻辑大致是:
async findDuplicateAccounts(companyName: string) { const normalized = this.normalizeCompanyName(companyName); const candidates = await this.accountRepo.createQueryBuilder('account') .where('similarity(account.name, :name) > 0.6', { name: normalized }) .orWhere('account.name_pinyin = :pinyin', { pinyin: this.toPinyinAbbr(normalized) }) .getMany(); return candidates; }这里用到了PostgreSQL的pg_trgm扩展的similarity函数,效果还行。但实际操作中业务员有时候还是会录重复,因为客户登记的时候用的公司名和工商系统注册名不一样,这个只能靠运营制度去约束,不能完全依赖系统。
客户分群和标签体系也是这个模块的重要组成部分。每个客户可以打多个标签,比如“超大型客户”“季度回款”“需求紧急”等。标签存储在客户表的一个tag_ids数组字段里,通过标签筛选客户时直接用数组包含操作,性能非常好。
我们后期还加了客户动态评分,综合客户活跃度、商机金额、跟进频次、最近联系时间等维度算出一个0到100的分数,分数高的客户自动置顶,业务员打开系统的第一眼就能知道今天该联系谁。
3.2 销售漏斗与商机阶段管理
销售的日常工作围绕商机展开,一个商机代表一个潜在的成交机会。商机阶段我们按照业务习惯设计了初识、需求分析、方案报价、商务谈判、赢单、输单六个阶段,每个阶段可以配置必填字段。
比如从“初识”推进到“需求分析”,必须填写客户预算、决策人、需求时间表;从“方案报价”推进到“商务谈判”,必须上传报价单和竞品信息。这些必填字段的作用是防止销售为了冲业绩虚假推进,也是数据质量的最后一道防线。
销售漏斗分析页面用ECharts做了漏斗图:
const funnelData = stages.map(s => ({ name: s.name, value: s.opportunityCount, }));同时统计每个阶段的转化率和平均停留时间。有一次数据分析发现,从“需求分析”到“方案报价”的转化率只有38%,远低于行业平均水平。后来逐个销售访谈才发现,问题出在方案产出太慢,技术顾问忙不过来,客户等不及就流失了。后来专门开发了方案模板库,销售和技术顾问可以基于模板快速拼装方案,转化率提升到了57%。
3.3 工单协作与自动提醒
工单模块是DeskcommCRM里我最得意的一块。它管的是客户购买后的服务请求,比如上门安装、远程调试、故障排查、定期巡检等。工单从创建到闭环,涉及的协作方很多——客服接待、技术派单、工程师接单、客户确认,任何一个环节卡住都会影响客户体验。
工单状态我设计了待受理、处理中、待客户确认、已关闭四个大状态,加一个已取消的终态。每个状态流转时都有通知规则,比如工单创建后10分钟没有受理,自动提醒客服主管;处理中超24小时没有更新,自动提醒项目经理;客户超过48小时不确认关闭,自动提醒跟进人回访。
自动提醒的实现用了一个定时任务加Redis延时队列。工单状态变更时,往Redis里塞一条带延迟执行时间的任务,时间到了之后检查工单状态是否满足提醒条件,满足就推通知。
伪代码大概长这样:
async scheduleReminder(ticketId: string, delaySeconds: number, reminderType: ReminderType) { const reminderKey = `reminder:${ticketId}:${reminderType}`; await this.redis.set(reminderKey, JSON.stringify({ ticketId, reminderType }), 'EX', delaySeconds + 60); // 延迟任务通过Bull队列触发 await this.reminderQueue.add( { ticketId, reminderType }, { delay: delaySeconds * 1000 } ); }这个设计上线后,客户对售后响应速度的投诉明显减少。关键不是提醒本身有多先进,而是有了它之后整个服务团队不敢随意丢单了,每张工单都有人在盯。
3.4 数据看板与报表
数据看板是管理层用最多的模块。我做了几个标准看板:销售业绩看板、商机分布看板、工单时效看板、客户健康度看板。每个看板支持按时间、部门、产品线维度筛选。
这里有个教训,报表需求开始用实时查询实现,结果后台数据库压力很大。后来我把报表数据改成了定时物化视图,每15分钟刷新一次。数据会滞后最多15分钟,但对于管理层看周报月报来说完全够用,数据库压力降了80%。
物化视图刷新用pg_cron定时任务:
CREATE MATERIALIZED VIEW sales_dashboard AS SELECT date_trunc('day', created_at) AS day, owner_id, COUNT(*) FILTER (WHERE status = 'won') AS won_count, SUM(amount) FILTER (WHERE status = 'won') AS won_amount, COUNT(*) FILTER (WHERE status = 'open') AS open_count FROM opportunity GROUP BY 1, 2; REFRESH MATERIALIZED VIEW CONCURRENTLY sales_dashboard;4. 实施过程中踩过的坑
4.1 数据迁移的意外困境
从旧系统切到DeskcommCRM,数据迁移是第一道坎。旧系统的客户数据存在不同的Excel里,格式五花八门,字段命名也完全不统一。有一次迁移联系人表,发现同一个联系人手机号有的加了+86前缀,有的没加,导致匹配不上。后来写了一段清洗脚本统一格式,才把数据质量提上来。
迁移完成后我做了随机抽样比对,发现还是有3%左右的客户数据因为缺少必填字段没有迁入。这些数据后来打包成CSV发给各业务线负责人,人工确认后补录。这个过程很痛苦,但也是逼着各业务线梳理自己客户资产的一次机会。
4.2 权限越权问题
权限越权的问题是在内测阶段暴露的。测试人员发现,一个普通销售可以通过修改URL中的参数查看其他销售的客户详细页。原因是最初我在应用层做权限过滤,但有些查询接口忽略了过滤条件。
后来我改成数据库RLS策略,彻底解决了这个问题。但这带来一个新的问题——某些场景下系统管理员需要查看所有数据,RLS策略会拦截管理员的查询。解决办法是给管理员角色设置一个特殊的current_setting值,RLS策略里放行。
这个坑给我的教训是:权限设计一定要在最底层做,靠应用层“记得”加条件是靠不住的,总有漏网之鱼。
4.3 性能瓶颈:列表页为什么越用越慢
系统上线一个月后,客户列表页变得越来越慢,最慢的时候要5秒多才能打开。排查发现,列表页用了Count(*)做总数统计,而客户表数据量已经超过30万行,每次打开页面都要全表扫描。
优化方案有两条:一是把总数统计改为估算值,PostgreSQL的EXPLAIN可以直接出估算结果,不精确但足够用;二是给列表页加Redis缓存,5分钟过期。两个方案叠加,页面响应时间降到了500毫秒以内。
列表页分页还有个细节,数据量大后LIMIT/OFFSET翻页越翻越慢。我把分页改成了基于游标的分页方式,前端传上一页最后一个客户的ID作为查询起点,性能稳定,不会因为翻到后面几页就变慢。这个改动在商机列表和工单列表同样适用。
5. 上线后的迭代与运营经验
5.1 推广落地比开发更难
系统开发完成只是第一步,真正的考验是让团队用起来。我原本天真地以为,只要系统好用,大家自然会用。现实是,销售们已经习惯了Excel和微信,让他们每天登录系统录入跟进记录,阻力非常大。
我后来改变策略,不是强制命令,而是先找几个配合度高的业务骨干做种子用户,把他们的使用体验打磨顺滑。比如销售最烦的是手动录跟进记录,我就开发了自动记电话的功能——销售通过系统内置的呼叫组件打电话,通话结束自动生成一条跟进记录,销售只需要补充总结即可。这个功能上线后,跟进记录的录入率飙升了4倍,因为省了太多事。
5.2 数据驱动的复盘机制
系统运行稳定后,我开始每周给管理层发一份数据周报,内容包括新增客户数、商机转化漏斗、工单平均处理时长、客户健康度评分分布等。有了数据,管理层开始主动关心系统里的信息是否准确,进而倒逼业务线认真维护数据。
有一份数据让我印象很深刻:客户健康度评分低于30分的客户中,有47%在过去90天内没有任何跟进记录。这些客户如果一直没人管,流失几乎是必然的。我们就此建立了客户挽救机制,健康度评分低于阈值的客户自动生成待办任务,推送给对应的客户经理。
5.3 持续迭代的方向
DeskcommCRM上线半年后,我列了一个后续迭代的路线图:
- 移动端适配:销售在外跑客户时,手机端打开频率远高于PC端,目前只做了H5轻量版,后续考虑做个React Native原生壳。
- AI辅助跟进建议:基于历史成功商机的跟进模式,给销售推荐最佳联系时间和沟通话术。
- 客户门户:让客户通过微信小程序自主提工单、查进度、看历史服务记录,减少客服转述环节。
- 报表自定义能力:用户能自己拖拽字段生成报表,不用每次需求都找研发。
6. 一点心得
如果让我重新做一次DeskcommCRM,我会在开工前花更多时间和销售、客服、现场工程师坐在一起,听他们讲讲一天的工作流,而不是只看管理者眼中的流程。系统最终是给一线人员用的,他们对效率的感知最敏锐,也最能提供有价值的改进方向。
另外,技术选型别盲目追新。这次用PostgreSQL的RLS、JSONB、物化视图,都是成熟稳定的功能,没有追求花哨的中间件,整个系统的运维负担因此低了很多。自研CRM本质是业务工程,不是技术炫技。把业务逻辑理清楚、权限安全做扎实、性能瓶颈提前预防,比用什么框架重要得多。希望这篇拆解能够给还在CRM选型和自研路上纠结的团队一些参考。