DeskcommCRM 这个项目,说白了就是一套客户关系管理系统,但它不是那种“装个开源软件就能跑”的通用货架产品。我从前年启动到现在,从需求梳理、数据模型设计到前后端落地、上线排坑,全程参与,踩了不少坑也沉淀了不少经验。这篇文章不聊 PPT,也不聊“数字化转型”这种大词,只讲实际开发过程中最核心、最值得记录的技术决策和实操细节。
如果你也准备做一套面向 B 端销售团队的业务管理系统,不管是叫 CRM 也好,还是客户跟进工具、销售管理后台,这篇内容应该能帮你少走很大一段弯路。我会把项目里真正难啃的部分——数据模型、权限隔离、销售漏斗实现、性能优化——掰开揉碎讲清楚,并给出我在生产环境里验证过的方案和代码。
1. 项目背景与整体设计思路
1.1 为什么一个销售团队需要自研 CRM
很多人觉得 CRM 不就是“记录客户信息 + 跟进提醒”吗,市场上现成的工具一抓一把,为什么要自己造轮子?这是我被问得最多的问题。实际做了之后我才明白,一套好用的业务系统,难点从来不在“有没有”,而在“适不适合”。
我们销售团队日常的触点包括电话外呼、微信沟通、上门拜访、售后工单、合同续约,这些数据如果散落在 Excel、个人备忘录和微信聊天记录里,管理者根本没法拿到统一的视图。更麻烦的是,行业属性带来的字段差异非常大——资产管理类客户要记录资产规模,软件类客户要记录配额和使用人数,设备销售类客户要跟踪安装调试节点。通用 SaaS 产品往往在这时候就露怯了:要么表单字段配置受限,要么报表统计逻辑不透明,要么二次开发成本高得离谱。
所以 DeskcommCRM 从立项第一天起就定了几个硬性目标:客户与联系人分级管理、商机跟进全程留痕、工单服务闭环、销售数据看板实时可查。权限上要支持“销售只能看自己的客户,主管看团队,管理层看全盘”这种经典分层。这些需求单独看都不复杂,但合在一起,对数据模型和系统架构的要求就不低了。
1.2 技术栈选型:我的几个关键决定
技术选型阶段我做过一次完整对比,最后确定了这样一套组合:后端使用 Python 的 FastAPI,数据库用 PostgreSQL 15,缓存用 Redis,前端用 Vue3 + Element Plus,部署在 Docker Compose 管理的两台云服务器上。
为什么不用 Java Spring Boot?不是说 Java 不好,而是团队实际情况决定的——我们组最熟的语言就是 Python,FastAPI 的 async 支持在处理通知推送、批量导出这类 IO 密集型任务时非常顺手,加上 Pydantic 做参数校验和序列化,开发效率明显高。业务系统的瓶颈绝大多数在数据库和模型设计,不在语言本身的执行速度上,这个阶段“开发快”比“运行快”更值钱。
数据库选 PostgreSQL 是一个让我非常庆幸的决定。CRM 这种系统天然要求灵活字段、复杂关联查询、全文搜索,PostgreSQL 的 JSONB 字段、GIN 索引、pg_trgm 模糊搜索扩展几乎是为这种场景量身定做的。后面我会专门展开讲字段设计和搜索优化的细节。
前端选 Vue3 是为了生态成熟,Element Plus 的表格、表单、弹窗组件能快速堆出后台管理系统。这套组合实测下来,一个人全职开发,从零到上线内测版本大概用了四个半月,人员再增加一两个人,进度还可以更快。
2. 核心数据模型设计:客户、商机与工单
2.1 客户分层表结构:扩展字段为什么放在 JSONB 里
客户表是整个系统的地基,地基没打好,后面所有功能都会跟着难受。我第一版设计时把客户常见的属性都做成了固定列,比如 company_name、industry、scale、source、owner_id、status、next_follow_time 这些。但上线测试没多久,销售就开始提需求:金融类客户要记录“持牌编号”,教育类客户要记录“校区数量”,还有一些客户需要打上“KA 重点客户”“沉默风险”之类的自定义标签。
如果每来一个需求就加一列,数据库会变成又臭又长的三明治,而且改表结构在 PostgreSQL 里虽然可以用 ALTER TABLE 在线操作,但迁移脚本多了之后代码维护成本会飙升。我最终采用的方案是“公共字段 + JSONB”的混合模式:
CREATE TABLE customer ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, name VARCHAR(255) NOT NULL, industry VARCHAR(100), source VARCHAR(50), owner_id BIGINT, team_id BIGINT, status SMALLINT DEFAULT 1, next_follow_at TIMESTAMPTZ, ext_attrs JSONB DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ );所有客户都会用到的检索维度(比如行业、来源、负责人、状态、下次跟进时间)做成固定列,并加上合适的索引;高度行业化、个性化、低频检索的字段一律放进 ext_attrs。然后在 ext_attrs 上建 GIN 索引,保证以后做标签筛选时也能走索引:
CREATE INDEX idx_customer_ext_attrs_gin ON customer USING GIN (ext_attrs);关于 JSONB 需要注意的坑是类型一致性。PostgreSQL 的 JSONB 对数字和字符串区分严格,如果写入的时候一个值是 123,另一个值是 "123",GIN 索引无法同时高效匹配这两个值。解决办法是业务代码层做统一清洗,所有 ext_attrs 的值在写库前强制转成字符串。我封装了一个工具函数,在 CRUD 入口统一处理,避免散落在各业务模块里反复踩雷。
2.2 商机与跟进记录:用流水表把过程留痕
商机(Opportunity)是销售系统的核心对象,它代表了一个潜在的成交机会。把商机做成独立的表,而不是挂在客户表下面,是因为一个客户可能同时有多个商机在推进,状态各自独立。
商机表第一版设计几乎没有争议,关键字段就是客户 ID、负责人、预期金额、预计成交时间、所处阶段、赢单概率。这里我想重点说的不是商机本身,而是跟进记录(Follow-up Log)的设计。
销售每天会往系统里录入大量跟进记录,一点不夸张,一天几千条很正常。为了支持“客户全生命周期追踪”,我把它设计成了不可修改的流水表,只允许追加,不允许 UPDATE 和 DELETE:
CREATE TABLE follow_up_log ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, customer_id BIGINT NOT NULL, opportunity_id BIGINT, user_id BIGINT NOT NULL, content TEXT NOT NULL, contact_time TIMESTAMPTZ, ext_attrs JSONB DEFAULT '{}'::JSONB, created_at TIMESTAMPTZ DEFAULT NOW() );为什么不允许改?业务上跟进记录本质是审计日志,销售通过更改记录来补写“当时没说出口”的情况,是常见的人性漏洞。技术上如果允许 UPDATE,就必须再维护一张变更日志表来记录是什么时候被谁改了什么,成本翻倍收益为零。直接把写入权限收死,任何历史跟进记录在系统里都改不了,这个规则上线后反而没人有异议,因为谁都怕同事改自己的跟进记录。
查询时所有历史记录按 customer_id + created_at 倒序拉取并分页展示,配合一个覆盖索引就能跑得很稳。这里要注意,created_at 用 TIMESTAMPTZ 存储,避免时区换算导致的时间展示错乱,这个在跨区域协作时尤其重要。
2.3 工单状态机:服务闭环的骨架
工单系统是给我印象最深的一块,因为它的业务规则比客户和商机都严格。客户提一个售后问题,工单要经过“待分配 → 处理中 → 待客户确认 → 已关闭”这样的流转,期间还允许“重新打开”,不允许“直接关闭未处理”,不允许“处理中直接跳回待分配”。
如果代码里到处写 if/else 判断状态,总有一个地方会漏判。我的做法是用状态机表来驱动:
CREATE TABLE ticket_state_transition ( id BIGSERIAL PRIMARY KEY, ticket_type VARCHAR(50) NOT NULL, from_state VARCHAR(50) NOT NULL, to_state VARCHAR(50) NOT NULL, required_role VARCHAR(50), is_active BOOLEAN DEFAULT TRUE );这个设计的意义在于,状态流转规则变成了数据而不是散落在代码里的判断逻辑。新增一种工单类型,或者在某个环节增加“加急处理”状态,改一条数据库记录、更新配置管理界面就行,不需要重新发版。后端在保存工单状态时统一调用状态校验函数,不满足跳转条件的直接抛业务异常。
我后来甚至把这个思路扩展到了商机阶段管理上——不同行业的销售流程不一样,有的行业需要先“提交方案”再“商务谈判”,有的则直接用“报价”覆盖这两个阶段。状态机配置化之后,每个租户都能自定义自己的阶段流转路径,产品灵活性一下子提升了一个档次。
3. 权限体系与数据隔离的实现
3.1 RBAC 之外还需要数据范围
权限设计是我这次开发中真正脱了一层皮的部分。刚做时想得很简单,用户表、角色表、用户角色关联表,标准的 RBAC(基于角色的访问控制)就完事了。真正上线前测试才发现完全不够,因为 CRM 里除了“能不能访问这个页面”,更重要的是“能看到哪些数据”。
一个普通销售登录系统,他不仅不能访问“系统设置”页面,也不能在自己的客户列表里看到别人的客户。主管可以看到自己组员的客户,但不能看到其他组的客户。这个“数据范围”的差异,比单纯的“页面权限”难处理得多。
我最终的做法是角色表中增加一个 data_scope 字段,取值为 self(仅本人)、team(本团队)、dept(本部门)、all(全部)。然后在所有查询入口统一拼上过滤条件:
def customer_query(user): q = select(Customer).where(Customer.tenant_id == user.tenant_id) scope = user.role.data_scope if scope == "self": q = q.where(Customer.owner_id == user.id) elif scope == "team": team_ids = get_team_ids(user) q = q.where(Customer.team_id.in_(team_ids)) elif scope == "dept": dept_ids = get_dept_ids(user) q = q.where(Customer.dept_id.in_(dept_ids)) return q这里有个很容易被忽视的时间坑:如果用户只是被分配了一个角色,但角色没有被正确配置 data_scope,系统会默认按最小的 self 范围来处理。宁可让用户看不到数据,也不能让他多看到数据。数据权限宁紧勿松,这是写权限模块时最值得记住的底线。
另一个提升代码质量的做法是把这个过滤逻辑做成 SQLAlchemy 的 query 扩展或者 FastAPI 的依赖注入,而不是让每个路由函数手写一遍判断。我当时就是在鉴权中间件里统一注入了当前用户的数据范围上下文,业务代码只关心业务本身,权限过滤规则集中管理,既减少重复代码也降低漏过滤风险。
3.2 多租户的隔离策略与索引设计
设计时我们确定了 DeskcommCRM 要支持多租户模式,也就是一套系统同时给多个公司使用。这里有两个主流方案:一个是每个租户独立 schema 甚至独立数据库,隔离性强但维护成本高;另一个是共享表、通过 tenant_id 区分数据,运维简单但要求所有 SQL 必须带租户条件。
我选了共享表方案,这是绝大多数 SaaS 产品的选择,运维最省心,升级表结构只用做一次。代价就是所有核心表都加了 tenant_id,并且在索引设计上必须把它放在最前面:
CREATE INDEX idx_customer_tenant_owner ON customer (tenant_id, owner_id); CREATE INDEX idx_opportunity_tenant_stage ON opportunity (tenant_id, stage);为什么要坚持 tenant_id 放索引最左边?因为 PostgreSQL 的 B-tree 索引最擅长处理最左前缀匹配,查询时先定位租户再缩小范围,能大幅减少扫描行数。我见过不少系统因为漏了这个条件,一个租户的数据误展示给另一个租户,这种安全事故在业务系统里是致命的。
租户隔离在 ORM 层我是通过全局过滤器实现的,所有新建的查询自动追加 tenant_id 条件,避免程序员写业务代码时忘带。如果团队没有这个约束,单纯靠代码自觉,一场事故迟早会来。
4. 核心功能模块的落地细节
4.1 销售漏斗看板:为什么先查聚合而不是实时统计
销售漏斗是管理层最看重的功能,它按照商机阶段统计金额和数量。第一版实现我直接对 opportunity 表做实时 GROUP BY,数据量小的时候没问题,但到了几千个商机、几千次的并发访问时就明显变慢,一个看板页面加载要三四秒。
后来我把漏斗统计改成了“预聚合 + 定时刷新”的策略。每天凌晨跑一次统计任务,把每个渠道、每个阶段的商机金额与数量写入一张统计表;看板页面只读统计表,响应时间稳定在 100 毫秒以内。
这里有一个取舍问题值得说清楚:实时统计的数据最准,但代价是慢;预聚合数据有最多 24 小时的延迟,但对管理层做决策来说完全够用。如果确实需要更实时的效果,可以缩小统计周期到每 15 分钟增量刷新一次,也可以把统计键值缓存进 Redis,权衡后我觉得这种折中是性价比最高的。
为了后面排查方便,我在统计表里加了统计维度字段和报告日期字段,这样同一天多次刷新不会产生脏数据。看板页面再根据日期范围做二次过滤,就能在明细和汇总两个粒度之间自由切换。
4.2 通知与第三方集成:消息可靠投递的一个坑
CRM 里不可避免要接消息通知,比如客户新建后通知负责人、工单状态变化后通知客户服务人员、商机阶段更新后同步到企业微信群。这块功能看似简单,实际上最大的坑是“消息发送成功”和“消息送达”完全是两回事。
我第一版实现是在业务代码里直接调用第三方 HTTP API 发送消息,如果第三方服务响应慢或者临时不可用,事务已经提交但通知发不出去。更麻烦的是,一旦消息 API 超时,是重试还是不重试?重试可能导致重复通知,不重试可能漏通知。
最终我用 Redis List 做了一个简单的消息队列,业务代码只负责往队列里 push 消息,后台 worker 从队列中 pop 并发送,发送失败则重新放回队列并记录重试次数。这个方案不复杂,却让通知子系统从业务主流程中彻底解耦。最重要的是,发送第三方通知的耗时从业务接口中移除了,用户操作响应时间明显提升。
4.3 搜索与数据导出的性能优化
客户列表的搜索功能上线初期表现不佳,销售输入一个客户名关键字,查询要等接近一秒。这是典型的前缀模糊匹配问题,PostgreSQL 原生 LIKE ‘关键字%’ 在有索引的情况下还行,但 LIKE ‘%关键字%’ 是无法走 B-tree 索引的,数据量大时全表扫描当然慢。
我的解法是启用 pg_trgm 扩展,它把文本拆成三元组并建立 GIN 索引,配合 pg_trgm 操作符可以大幅加速包含匹配搜索:
CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_customer_name_trgm ON customer USING GIN (name gin_trgm_ops);需要说明的是,这个索引对中文支持得不错但也不是万能的,比如单字搜索效果就很一般。实际使用中我建议限制最少搜索长度为 2 个字符,太短的查询直接走普通过滤,避免触发器计算开销浪费。
数据导出是另一个杀手级性能瓶颈。销售点一次“导出当前筛选客户”,后台如果同步生成 Excel,几百条数据没问题,几万条数据就会把内存占光甚至卡死服务。我的方案是导出请求先进异步任务,Redis 队列里消费,生成文件后上传到对象存储,再把下载链接推送给用户。用户点击导出后可以继续干别的,文件好了再下载,体验反而更好。
5. 上线后的常见问题与排查笔记
5.1 并发更新丢数据:乐观锁解决商机金额覆盖
上线第二周,销售团队反馈了一个严重问题:两个销售同时编辑同一个商机的金额,后保存的人把先保存的人的数据覆盖掉了。这类问题在 Web 系统里很常见,本质上是并发更新的读改写冲突。
我的修复方案是给 opportunity 表加了 version 字段,更新时在 SQL 里带上检查条件:
UPDATE opportunity SET amount = ?, version = version + 1 WHERE id = ? AND version = ?如果更新的影响行数是 0,说明有人在当前事务提交前改了记录,程序直接返回“数据已被他人修改,请刷新后重试”。这个方案成本极低,PostgreSQL 的原子 UPDATE 保证不会出现并发写串行问题。需要提一下的是,乐观锁适合冲突不频繁的业务场景,如果冲突频繁,用户会频繁看到“请刷新后重试”的提示,那就该考虑把并发度降下来而不是继续在锁层面纠缠了。
5.2 越权访问:一次安全评审暴露的问题
有次安全评审时测试工程师发现,一个普通销售把浏览器地址栏里的客户 ID 换成另一个大数字,居然能打开别人的客户详情页。这是典型的 IDOR(Insecure Direct Object Reference)漏洞,原因是我某个接口在查询时只校验了登录状态,没有校验数据权限。
问题暴露后我把所有携带 ID 参数的业务接口全部梳理了一遍,统一加了资源归属校验:
async def get_customer(customer_id: int, user: User): customer = await db.get(Customer, customer_id) if not customer or customer.tenant_id != user.tenant_id: raise HTTPException(status_code=404, detail="Not Found") if not has_data_permission(user, customer.owner_id, customer.team_id): raise HTTPException(status_code=403, detail="Forbidden") return customer这里要特别强调一个细节:数据不存在和没有权限的返回要统一。如果用户没有权限就返回 403,测试人员很容易判断出资源存在但无权访问;统一返回 404,可以在一定程度上降低被恶意探测的风险。安全测试人员后来也认可了这个处理方式。
5.3 工单状态被跳过:加一个状态变迁校验器
工单模块上线后出现了“已关闭的工单被重新打开后,没有走重新处理流程而直接跳到已完成”的 bug。排查后定位原因是状态切换逻辑只做了“当前状态是否允许修改”的判断,没有做“目标状态是否合法可达”的判断。
比如一个工单处于“待客户确认”状态,理论上它只能流向“已关闭”或“重新打开”,但某段代码里写死了一个“直接置为完成”的接口,于是状态就跳过了中间的必经节点。这个问题用状态机配置表就能优雅解决,我在前面已经提到过,这里补充一下实际处理方式:先拉取当前工单类型的全部合法迁移路线,再判断目标状态是否在这些路线的 to_state 中,最后用事务把状态更新和记录写入一起提交,保证规则校验和状态变更要么全部成功、要么全部不生效。
这件事也让我养成了一个习惯:任何有状态流转的业务对象,都先把状态图画清楚再写代码,不要边写边拍脑袋。状态图不画好,后面补逻辑的代价比一开始设计大十倍。
5.4 邮件通知进垃圾箱:SPF / DKIM 配置
系统上线后会定期向客户发送回访邮件、续约提醒邮件。内测阶段一切正常,正式发了一批后发现大量邮件进了客户的垃圾箱,甚至有一些直接被邮件服务商拒收。
排查后定位是发件域名没有配置 SPF 和 DKIM 记录。通俗解释一下:邮件服务商判断一封邮件是否可信,会去查发件域名的 DNS 记录,SPF 记录声明了哪些服务器允许代发该域名的邮件,DKIM 记录则通过签名验证邮件内容是否被篡改。没有这两条记录,邮件服务商大概率会把你的邮件归类为高风险垃圾邮件的候选项。
解决办法是登录域名 DNS 管理页面,添加 SPF 的 TXT 记录指向邮件服务商提供的服务器地址,同时配置 DKIM 记录的公钥。两条记录生效后(注意 DNS 刷新最长可能需 24-48 小时),邮件送达率有了非常明显的提升,退信率从原来的 8% 左右降到了 0.5% 以下。这件事技术含量不高,但对业务影响极大,值得任何做邮件通知的项目提前处理好。
回到 DeskcommCRM 这个项目本身。从最初的几张表到正式上线跑通,给我最大的体感是:业务系统的复杂度从来不是来自某个高深算法,而是来自大量业务流程细节的相互纠缠。客户要分层、商机要状态、工单要流转、每个角色看到的数据范围不同、每次统计还要保证性能,每块单独拎出来都不难,但组合在一起就非常考验全局设计能力。
如果你正在做或者准备做一个类似的业务系统,我的建议是先把数据模型画清楚,再写第一行业务代码。客户、商机、工单怎么抽象,字段哪些做成固定列、哪些放进 JSONB,权限范围怎么界定,这些想明白了,后面开发推进会顺畅很多。反之,等代码写到两万行再回头调整模型,那个成本足够让你怀疑人生。
我在实际开发中还有一个心得:不要为了“设计模式”强行抽象。像状态机配置化这种方案是真的简化了业务,而有些为了“未来可能有用”而引入的过度设计,最后往往变成调试时的负担。好的架构是在业务压力下自然生长出来的,不是一开始就套一个大而全的框架。这一点,做业务系统的人应该都懂。