资讯动态

从Excel到自研轻量CRM:DeskcommCRM的架构设计与工程实践

发布时间:2026/10/19 4:02:31 来源:尧图企业网站定制
从“销售靠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_atcontacts联系人id, customer_id, name, phone, email, position, is_primarydeal_pipelines管道定义id, name, stages(JSONB), is_activedeals商机/交易id, customer_id, pipeline_id, stage, amount, close_dateactivities跟进事件id, customer_id, actor_id, action, detail(JSONB), occurred_atreminders提醒任务id, activity_id, remind_at, channel, statuscustomer表存的是相对静态的客户属性比如来源、所属行业、当前生命周期阶段。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: oldexample.com, after: newexample.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_size10, max_overflow20第三在数据库侧设置idle_in_transaction_session_timeout30s作为最后的兜底手段。改完之后连接数稳定在十几条再也没发生打满的情况。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比任何花哨的技术选型都重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑