
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 不好而是团队实际情况决定的——我们组最熟的语言就是 PythonFastAPI 的 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另一个值是 123GIN 索引无法同时高效匹配这两个值。解决办法是业务代码层做统一清洗所有 ext_attrs 的值在写库前强制转成字符串。我封装了一个工具函数在 CRUD 入口统一处理避免散落在各业务模块里反复踩雷。2.2 商机与跟进记录用流水表把过程留痕商机Opportunity是销售系统的核心对象它代表了一个潜在的成交机会。把商机做成独立的表而不是挂在客户表下面是因为一个客户可能同时有多个商机在推进状态各自独立。商机表第一版设计几乎没有争议关键字段就是客户 ID、负责人、预期金额、预计成交时间、所处阶段、赢单概率。这里我想重点说的不是商机本身而是跟进记录Follow-up Log的设计。销售每天会往系统里录入大量跟进记录一点不夸张一天几千条很正常。为了支持“客户全生命周期追踪”我把它设计成了不可修改的流水表只允许追加不允许 UPDATE 和 DELETECREATE 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 换成另一个大数字居然能打开别人的客户详情页。这是典型的 IDORInsecure 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_code404, detailNot Found) if not has_data_permission(user, customer.owner_id, customer.team_id): raise HTTPException(status_code403, detailForbidden) 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权限范围怎么界定这些想明白了后面开发推进会顺畅很多。反之等代码写到两万行再回头调整模型那个成本足够让你怀疑人生。我在实际开发中还有一个心得不要为了“设计模式”强行抽象。像状态机配置化这种方案是真的简化了业务而有些为了“未来可能有用”而引入的过度设计最后往往变成调试时的负担。好的架构是在业务压力下自然生长出来的不是一开始就套一个大而全的框架。这一点做业务系统的人应该都懂。