☰
从Excel到自研轻量CRM:DeskcommCRM的架构设计与工程实践
2026/10/19 4:02:30 网站建设 项目流程

从“销售靠Excel管客户”到“我必须自己写一个CRM”,这个转折我用了大概两年时间。起初团队只有七八个人,客户一多,表格里出现“名字对不上”“跟进状态忘了改”“同一个客户两个人同时在聊”的情况越来越频繁。市面上不是没有CRM产品,可要么功能重到需要配专人维护,要么按年付费的价格在中小团队身上就是一笔不小的负担。于是我决定自己动手,做一个轻量、自托管、把客户管理和内部协作拧在一起的系统,这就是DeskcommCRM的由来。这篇文章会把整个项目的来龙去脉讲清楚,从需求拆解、数据模型设计、核心功能实现,到部署上线后遇到的一堆真实问题,都会尽量落到细节。如果你也在考虑自研CRM,或者正在给团队选型,这篇文章应该能给你提供一个不一样的参考角度。

1. 为什么做DeskcommCRM:团队协作型CRM的痛点解剖

1.1 从“Excel管理客户”到“必须上系统”的分水岭

很多团队一开始都觉得Excel够用。我们也是——客户名单、联系人、最近一次沟通记录、下一步计划,全部塞进一张共享表格里。但真正的问题不在存储,而在“协作”:当两个人同时编辑一个客户的信息,后保存的那个人会把前面的改动悄悄覆盖掉;当销售外出回来想更新跟进状态,却发现表格里压根没有统一的数据规范,有人写“客户已报价”,有人写“等回复”,统计口径完全对不上。

我自己印象最深的一次是,一位同事跟了一个月的重点客户,结果另一个销售也在跟同一个联系人,双方都不知道对方发过报价。最后客户两头对比价格,直接对我们整体印象打了折扣。这个教训让我意识到,客户数据不是一张“列表”,它是一段不断演进的关系记录,必须围绕“谁在什么时候做了什么”来组织。这个认知后来直接决定了DeskcommCRM的核心数据结构。

1.2 现有CRM产品在中小团队场景下的三个老大难

不是没想过买现成的。当时团队也试过几个主流CRM,包括国际大牌和国内厂商的产品,试用一圈之后,我总结出三个绕不开的问题。

第一是价格结构不匹配。大牌CRM通常按用户数按年收费,看上去每人每月几十美元,听起来不贵,可一旦算上管理员培训、集成插件、API调用额度,实际成本往往翻倍。对一家现金流敏感的初创公司,这笔钱花得并不痛快。

第二是定制成本太高。销售团队人员的想法很朴素,看板要能拖拽、字段要能自定义、提醒要能挂到企业微信和邮件。这些功能在通用型CRM里通常深浅不一:改个字段名要摸半天配置,想要一个属于自己业务节奏的管道,往往需要买更高一级的付费套餐。

第三是协作数据割裂。通用型CRM普遍强在“管理”,弱在“沟通”。客户跟进备注在CRM里,聊天记录在IM里,通话记录在另外一套系统里。团队成员每天要在好几个页面之间来回切换,信息越铺越散,最后CRM反而成了摆设,大家还是回到群里问“这个客户到底聊到哪一步了”。

DeskcommCRM的定位从一开始就很明确:它不需要覆盖大企业的复杂组织架构,只要把“客户档案、跟进过程、内部协作提醒”三件事做到顺畅,让一个十人左右的团队第一天就能上手。

2. 整体架构与数据模型设计:先把“客户档案”这件事想透

2.1 模块划分与部署形态

DeskcommCRM没有做成微服务,当前阶段不需要。整个系统分为三个部分:前端、后端、数据库和消息推送组件。技术栈的选择标准也很简单,团队里有什么人、维护成本最低的是什么,就用什么。

  • 前端:Vue 3 + Element Plus,负责页面交互和看板拖拽;
  • 后端:Python FastAPI,提供REST API,处理业务逻辑和异步任务;
  • 数据库:PostgreSQL,存业务数据和事件记录,同时承担行级权限控制;
  • 缓存与推送:Redis,用于WebSocket消息转发和定时任务的执行状态;
  • 部署:Docker Compose一键拉起,保证开发环境和生产环境一致。

这个组合的取舍点在于,FastAPI的生态虽然不如Spring Boot庞大,但它的异步模型和自动生成OpenAPI文档,足以应付这个体量的项目,而且迭代速度真的快。数据库选型上,PostgreSQL的行级安全策略(RLS)对我们这种“客户归不同人管理”的业务场景几乎是量身定做。关于这一点,后文我会单独展开。

2.2 数据模型:客户、联系人、跟进事件如何关联

设计数据模型之前,我先把业务语义理清楚。在DeskcommCRM里,最核心的概念不是“客户”,而是“跟进事件”。客户只是一个聚合信息的载体,联系人是客户档案里的关键角色,而让整个档案“活”起来的,是一串串带时间戳、带操作人、带状态变化的事件流。

我建了下面几张核心表:

表名用途关键字段
customers客户主档id, name, owner_id, status, source, created_at
contacts联系人id, customer_id, name, phone, email, position, is_primary
deal_pipelines管道定义id, name, stages(JSONB), is_active
deals商机/交易id, customer_id, pipeline_id, stage, amount, close_date
activities跟进事件id, customer_id, actor_id, action, detail(JSONB), occurred_at
reminders提醒任务id, activity_id, remind_at, channel, status

customer表存的是相对静态的客户属性,比如来源、所属行业、当前生命周期阶段。deal表属于动态的销售漏斗,每个商机挂在具体客户之下,看板页面直接按管道阶段聚合展示。activities表则记录每一次与人相关的动作,新增联系人、发了报价、打完电话、改了阶段、添加了一条备注,都会追加一条事件。

这样的设计有一个明显好处:客户档案不是一张需要反复更新的“快照”,而是一条可以回溯的时间线。新人接手客户时,不需要翻聊天记录,只需要打开时间线,就能清楚看到这个客户从进入系统到现在的完整经过。

2.3 为什么用PostgreSQL而不是MySQL

选数据库时,团队里有人提议MySQL,毕竟资料多、大家熟。但我坚持用PostgreSQL,核心原因有三条。

第一,JSONB字段非常顺手。管道的阶段配置、跟进事件的附加信息、自定义字段的扩展,都可以用JSONB存,既不需要频繁改表结构,又能在需要时对JSON内部的字段建索引。

第二,行级安全策略(RLS)能直接把“数据归属”下沉到数据库层。以前这种权限判断通常写在应用代码里,每写一个查询都要拼一段owner过滤条件,漏一个地方就会越权。PostgreSQL的RLS允许我在数据库层定义规则,应用层即使忘记过滤,数据库也会阻止用户读到别人的客户。

第三,PostgreSQL的物化视图和窗口函数,在后续做业绩统计时表现更好。比如要看某个人在一个季度内每个管道阶段的停留时长,SQL里几个窗口函数就能搞定,不用把数据拉到应用层再哗啦啦计算一遍。

3. 核心功能实现细节:管道看板、客户时间线、自动提醒

3.1 管道看板:状态机驱动的前后端配合

管道看板是销售团队每天使用频率最高的页面。DeskcommCRM里的管道不是写死的一二三阶段,而是允许管理员自定义的。数据库里用JSONB存阶段定义,比如:

{ "stages": [ {"key": "lead", "name": "初步沟通", "color": "#909399"}, {"key": "quoted", "name": "已报价", "color": "#409EFF"}, {"key": "negotiating", "name": "商务谈判", "color": "#E6A23C"}, {"key": "won", "name": "赢单", "color": "#67C23A"}, {"key": "lost", "name": "输单", "color": "#F56C6C"} ] }

前端看板拖拽卡片后,调用接口传的是deal_id和target_stage,后端接口里做的事情不是简简单单更新一个字段,它记一条活动事件,记录谁、在什么时间、把哪个商机从哪个阶段拖到了哪个阶段,同时更新deal的stage。如果目标阶段是“赢单”,系统还会自动触发一个后台任务,把客户状态同步改成“已转化”。

这里有一个容易踩的坑:前端拖拽成功之后,后端必须返回完整的新stage数据,不能只返回“ok”。因为看板页面是多端并发的,A同事和B同事同时拖拽同一个deal时,返回完整状态才能让前端及时校正本地展示,减少数据覆盖冲突。

3.2 客户时间线:事件溯源思想在业务层的轻量落地

时间线的设计思路借鉴了事件溯源(Event Sourcing),但做了一层简化——不要求系统状态完全由事件流重建,只是把所有业务动作“附加”记录到activities表里。这样既拿到了完整审计链,又不用背上事件溯源的重负担。

每个动作写入时,detail字段里会带一个before和after的JSON片段。比如修改联系人邮箱,detail里就记录{"before": "old@example.com", "after": "new@example.com"}。这样时间线的展示层可以直接判断有没有before/after,有就渲染成“张三 修改了联系人的邮箱,从xxx改成yyy”,没有就渲染成“张三 拨打了电话”,完全不需要额外的字段定义。

接口设计上,时间线走的是不做分页、直接游标翻页的方式。客户详情页默认加载最近50条事件,往下滚动时根据cursor(即occurred_at + id的组合)继续拉取。这个方案在数据量达到几十万条事件之前都足够稳,而且比“页码翻页”更贴合时间线按时间顺序阅读的直觉。

3.3 自动提醒:APScheduler任务调度的两个关键坑

提醒功能是另一个高频需求。销售提交“三天后跟进报价”之类的计划后,系统要在指定时间通过企业微信机器人和邮件提醒当事人。技术选型用了APScheduler,挂在FastAPI应用里作为后台调度器。它本身是个成熟库,但在实际项目中,有两个问题特别容易翻车。

第一个坑是长期运行的内存泄漏。APScheduler默认把任务存储在内存里,服务重启后任务丢失。解决方法是把任务存到数据库,同时给任务绑定一个misfire_grace_time。这个参数的意思是:如果服务因重启或占用过高导致任务没按时触发,系统在宽限时间内可以补执行。我在生产环境设了60秒的宽限期,太短容易漏,太长则会让“提醒”失去意义。

第二个坑是时区。后面会专门讲一次真实的时区事故,这里提一点:APScheduler的timezone必须和业务约定的时区一致,而且提醒时间入库时要统一存成UTC,展示时再转换为本地时区。否则就会出现“我在配置界面选了早上9点提醒,实际却是下午5点收到”的诡异现象。

4. 权限与客户归属:给团队立规矩

4.1 三种可见性模型怎么选

客户数据的可见性绝不是“管理员看所有、其他人看自己”这么简单。我梳理了团队真实工作流之后,把可见性模型分成了三种,分别对应不同角色和场景。

  • 私有模式:客户归属人本人可见,适用于销售专员日常跟进阶段,避免同事间互相干扰。
  • 团队共享模式:同部门或同小组可见,适用于协作型客户,比如一个KA客户由销售经理、方案顾问、售后三人共同维护。
  • 全局只读模式:所有成员可查看但不能修改,适用于管理层复盘和跨部门协作场景。

这里我刻意没有把“管理员看所有”作为默认选项,因为很多团队一开始就栽在权限一刀切上。把管理员的“查看权限”和“操作权限”拆开,让管理员能看所有数据做分析,但系统记录下的日志里,他的每次查看也能被审计,反而是更稳妥的做法。

4.2 PostgreSQL行级安全策略的实际配置

DeskcommCRM在数据库层直接落地了权限控制。以下是简化之后的配置思路:

-- 开启行级安全 ALTER TABLE customers ENABLE ROW LEVEL SECURITY; -- 私有模式策略:客户归属人可读写 CREATE POLICY customer_owner_policy ON customers USING (owner_id = current_setting('app.current_user_id')::int); -- 团队共享模式策略:所在团队可读 CREATE POLICY customer_team_policy ON customers USING (team_id IN ( SELECT team_id FROM user_teams WHERE user_id = current_setting('app.current_user_id')::int ));

应用层在每次数据库会话建立时,通过SET app.current_user_id = ?把当前登录用户ID注入会话变量。这样所有查询都天然携带权限过滤条件,哪怕是后端某个接口忘了加业务层的权限判断,数据库也会兜底拦截。这是我觉得整个系统设计里性价比最高的一个决定,它把最容易出越权问题的环节,从根本上压住了。

4.3 变更日志与操作审计

光有权限还不够,出事之后要能追溯。前面说的activities表承担了业务时间线的职责,但它记录的是面向销售人员的“友好事件”。另建了一张audit_logs表,记的是面向系统维护的“原始日志”,包括请求路径、请求方法、修改前后的完整数据、操作人IP、User-Agent等。两张表的分工,业务人员看前者,技术人员查后者,互不干扰。

审计日志的写入放在后端中间件里统一处理,不散落在各个业务代码里。Flask/FastAPI的中间件机制都能做到这一点,只需要注意过滤掉GET请求和心跳接口,否则日志表很快就会被只读操作塞满。

5. 部署实战:从开发机到正式环境踩的三个坎

5.1 时区问题:时间线里的记录“消失”两小时

第一次部署到云服务器后,有销售反馈说客户时间线里某条记录“消失”了。我一看数据库,记录明明存在。再仔细对比,才发现是时区问题:服务器操作系统是UTC,客户从浏览器提交更新时间时,前端传的是当地时间,后端只做了简单的时间戳转换,没统一转成UTC存库。结果数据库里的occurred_at比真实时间慢了8小时,按当天的日期范围过滤时,晚上8点到12点之间创建的事件就被“算”到了下一天。

修复方案很简单也很有代表性:前端传时间时统一用ISO 8601带时区格式;后端入库前统一转成UTC;查询时把UTC转回业务时区再做分组和过滤;展示层全部用浏览器本地时区渲染。这样问题的关键不在于后端用的时区函数库,而在于全链路有没有一条明确的时间处理规则。

5.2 数据库连接池被打满的根因

系统上线第三周,某天下午突然收到告警,PostgreSQL连接数飙到500多,数据库几乎没有响应。一开始怀疑是慢查询,看了一遍都没有特别异常的SQL。后来翻日志才发现,是FastAPI的异步Session管理写得不严谨——部分代码里用了@asynccontextmanager,但在请求异常时没有正确关闭连接,每个失败请求都会泄漏连接。

最后做了三件事:第一,所有数据库访问统一走依赖注入的get_db,确保上下文退出时无论成功失败都会释放连接;第二,限制SQLAlchemy连接池上限,pool_size=10, max_overflow=20;第三,在数据库侧设置idle_in_transaction_session_timeout=30s,作为最后的兜底手段。改完之后连接数稳定在十几条,再也没发生打满的情况。

5.3 Docker容器里收不到WebSocket消息

DeskcommCRM的看板页面用了WebSocket做实时刷新,A同事拖拽卡片后,B同事的页面能在几秒内看到变化。但Docker部署之后,容器外面能连通HTTP服务,WebSocket却一直握手失败。

排查链路是这样:先看Nginx配置,发现默认配置没有Upgrade头,于是补上了proxy_set_header Upgrade $http_upgrade和Connection "upgrade";接着又发现应用的WebSocket路径和HTTP的路径在Nginx里被一起代理到了同一个后端端口,但FastAPI的WebSocket路由需要单独匹配/ws前缀,于是又加了一条独立的location规则。这里想提醒大家,Docker部署不是把镜像跑起来就完事,反向代理层面的协议升级、路径匹配、超时设置,每一项都能成为“本地好好的,一上服务器就坏”的元凶。

6. 实际运行效果与后续迭代方向

DeskcommCRM在团队里跑了半年,目前50多个销售和运营人员日常使用,数据库里的客户主档超过一万条,跟进事件累计到十几万条,整体运行还算平稳。最直观的效果是,新人接手客户不再“一头雾水”,打开时间线就能按顺序了解所有沟通背景。管理层做业绩分析的时候,也不需要求着销售在群里报数,从系统里导一下管道数据就有底。

要说踩过的坑,其实都集中在最基础的工程问题上:时区、连接池、权限控制、WebSocket反代。这些问题如果一开始就按规范来,后面能省很多事。我现在回头翻代码发现最大的收益,反而是当初坚持“所有业务动作都写事件流”的决定——很多后续新功能都是在时间线这个底座上长出来的,比如自动生成客户周报、计算阶段停留时长、统计销售跟进活跃度。

下一步的计划,是给DeskcommCRM加一个轻量级的自动化规则引擎,让用户自己配置“当客户进入报价阶段超过3天且无跟进,自动通知负责人”之类的触发条件。另外客户端的移动适配也在排期里,毕竟销售在外跑客户时,拿手机快速看一眼时间线远比回工位打开电脑方便。自研CRM这件事,如果你问我要不要重做一遍,我的答案是:先想清楚团队真实的工作流,再动手写第一行建表SQL,比任何花哨的技术选型都重要。

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

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

立即咨询