资讯动态

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

发布时间:2026/10/18 3:42:06 来源:尧图企业网站定制
1. 为什么还要做一个 CRMDeskcommCRM 的由来1.1 从一张 Excel 表开始的真实需求DeskcommCRM 这个名字最初只是我随手起的内部代号没想到最后真的长成了一套能用的客户管理系统。项目持续了大半年起因不是老板拍板要数字化而是我们团队自己先被一张《客户跟进总表》逼疯了。当时的情况很典型销售、交付、客服三个角色共用一张在线表格谁都可以打开编辑但没人知道上一版是谁改的、昨天那条跟进记录是不是最新。客户名在不同的同事嘴里有不同叫法工商注册名、客户简称、备注里的英文名经常是同一个人。更麻烦的是不同人维护的列完全不一致有的填了下次跟进时间有的只写了已联系数据根本没法统计。我最初只是想找一个替代表格的工具结果看了市面上一圈 CRM 之后发现要么像销售易、Salesforce 那样功能重到需要专门配置顾问要么像一些轻量工具一样只能管客户姓名和手机号根本覆盖不了一个客户从线索到交付中间有几十次交互这个场景。就是在这样一个CRM 太重、Excel 太轻的夹缝里我决定自己动手这成了 DeskcommCRM 立项的起点。1.2 不是再造轮子而是补上中间地带有人问过我为什么不咬牙买一套商业 CRM说实话预算倒不是最大问题真正的问题是业务对象不够灵活。我们公司做的是跨境电商衍生服务客户类型分平台卖家、独立站卖家和代运营需求方每一类的资质字段都不一样。商业 CRM 的定制字段通常要升级到企业版才解锁而且修改字段后已有数据的清洗逻辑很别扭。再加上我们需要把客户跟进记录和内部项目进度打通商业软件很难做到这种半业务半协作的深度定制。所以 DeskcommCRM 的定位非常明确面向 20 到 80 人的中小团队以客户对象为底座强调跟进留痕、权限划分和字段可配置。对比大型 CRM它不追求销售自动化算法不做复杂的工作流只把客户数据统一管理这一件事做透。对比 Excel它又保证了并发编辑的稳定性和权限的严谨性。项目启动前我花了两周画原型和梳理需求最后只保留四张核心表这个减法过程比开发本身还重要。2. 需求收敛第一版只做四件事2.1 四个核心业务对象客户、联系人、商机、订单很多 CRM 项目死于什么都要做。我在需求阶段最常见的心理冲动就是加上营销邮件功能、加上在线客服、加上销售预测报表。但冷静下来之后我把第一版范围砍到了四个对象客户Account企业级客户主体是整个系统的根。联系人Contact客户公司里的具体沟通对象一个客户下面挂多个联系人。商机Opportunity潜在的销售机会关联客户和金额带销售阶段。订单Order成交后的业务单据关联商机和金额有回款状态。这四个对象形成了一个清晰的漏斗客户 → 商机 → 订单。联系人则是客户下面的子资源不单独进入漏斗。这个数据模型看起来简单但保证了后面动态表单、看板、权限都能围绕同一个主键体系运转。第一版我甚至刻意没有做线索Lead表因为线索本质上可以用客户状态未成单来表达少一张表查询和权限就少一层复杂度。2.2 动态表单与字段自定义需求阶段我访谈了销售和交付同事大家提得最多的一个需求是这个客户最好能记信用证条款、备案号、供应商等级。这些字段每个客户类型都不一样预先写死在数据库表里显然不现实。动态表单就成了第一版必须做的基础能力。思路是字段定义不写死在代码里而是存成元数据。每张业务表对应一个form_schema它是一个 JSON 结构描述了有哪些字段、字段类型、是否必填、下拉选项是什么。前端根据这个 schema 动态渲染表单后端根据 schema 主动解析和校验数据。这样运营的同学在系统里新增一个字段不需要改一行代码后端也只需要读取配置再校验不用为了新增字段出一次版本。这个设计有一个隐含的好处DeskcommCRM 的各个对象页面天然长得不一样却共用同一套渲染引擎后期的维护成本被压得很低。代价则是查询和统计需要处理 JSON 字段的存储这一点我在数据模型章节专门展开说。2.3 时间线追踪所有动作都留下痕迹销售管理的核心不是数据本身而是沟通的连续性。我见过很多同事跟客户聊了两个星期然后因为换人交接新接手的人完全不记得之前聊过什么只能翻聊天记录和邮件。DeskcommCRM 里给每个客户和商机都设计了一个统一的时间线Timeline把电话沟通、微信记录摘要、邮件正文、线下拜访纪要和系统内的字段变更全部按时间顺序揉在一起。实现上我采用了一张events事件表event_type标记动作类型payload用 JSONB 存具体内容。这样不管前端展示的是张三更新了商机金额还是李四记录了一通电话都走同一条查询逻辑。第一版上线后我发现使用率最高的功能就是时间线因为省去了无数条刚才那个客户聊到哪了的群消息。3. 技术选型背后的取舍3.1 前后端技术栈也踩过全栈统一的甜蜜陷阱技术栈选择上我给自己定的原则是团队成员最熟什么用什么不追新框架。当时我和另一个同事都是 TypeScript 的背景所以前端干脆用 React TypeScript Antd后端用 NestJS数据库用 PostgreSQL Prisma ORM。整套技术栈共享类型定义从接口到实体都能生成类型确实省了不少联调成本。这里有一个想提醒大家的点全栈 TypeScript 虽然统一但容易在service 层和数据库类型复杂化时失控。刚开始我也天真地以为前后端共用类型能解决所有问题实际上 Prisma 生成的类型和前端表单 schema 的灵活性天然冲突动态字段在静态类型里根本没法表达。最后我采用了分层策略基础领域对象用 Prisma 类型动态字段对象全部走一个RecordValue接口前端用 zod 做运行时校验而不是依赖编译期类型。3.2 为什么用 PostgreSQL 而不是 MySQL如果只是存储常规的业务记录MySQL 完全够用。但 DeskcommCRM 有动态表单和多租户两个硬需求PostgreSQL 的JSONB 类型和行级安全Row Level Security就成了我们的最大底气。动态字段数据可以存进一个record_values表每行是一对record_id field_key value也可以直接在业务表加几个 JSONB 列。我最终选了后者理由是大部分客户的基本字段都是固定的只有少部分扩展字段需要 JSONB如果全部做 EAV查询时要把几十行拼成一条记录性能会很差。而 PostgreSQL 的 JSONB 可以直接建 GIN 索引我做了一个按客户名模糊搜索 按自定义字段匹配的联合查询线上百万级数据下依然能稳定在 50ms 以内。3.3 多租户的实现同一库不做每个客户一套数据库DeskcommCRM 一开始要服务多个团队但我没有采用常见的一租户一库方案而是在每个业务表上都加上tenant_id列并且打开 PostgreSQL 的 RLS 约束。这样应用层查询时只要保证SET app.current_tenant被正确设置数据库层就会自动限制只能看到当前租户的数据哪怕业务代码里漏写了where tenant_id xxx也不会造成被租户数据串读的事故。RLS 的核心收益是权限下沉到数据库层应用层代码因此非常干净。代价是连接池要小心处理会话级设置每个请求都必须在一个事务里先set_config再执行业务查询。这个坑我们上线初期踩过当时因为某条查询走了非事务连接导致租户隔离失效多亏压测时发现了。细节放在后面排查清单里讲。4. 数据模型设计两次重构踩过的坑4.1 第一版传统关联表设计的无力感第一版我按照普通业务系统思维设计了accounts、contacts、opportunities、orders四张独立的表每张表都提前预留十几个字段license_file、tax_id、industry_type、platform_type等等。当时想着这个字段总会用上先加上再说。结果不到两周就出问题了。销售组反馈需要记录目标国家交付组需要记录服务里程碑这些字段预告不到我只能频繁改表。改一次表Prisma 要生成一次 migration上线还需要停服维护窗口。更麻烦的是每个对象对应不同的自定义字段组合传统关系型表的列越多查询和展示的逻辑就越混乱。到第二次需求评审时我已经意识到固定字段的 schema 完全走不通。4.2 第二版固定重要字段 JSONB 扩展字段第二次重构我把表的模型改成了这样每个业务对象都保留全世界系统都需要的核心字段例如名称、负责人、创建时间、更新时间、租户 ID其余业务扩展字段不再建列而是全部放进一个custom_fieldsJSONB 字段字段的元数据描述也就是字段名、显示名、类型、候选值存到form_schema对应的元数据表里。这个方案的好处是数据库的固定列可以保持很干净而扩展字段的增删改查全部走同一套读取逻辑。举个例子客户列表要显示目标国家这一列前端从form_schema拿到这个字段的key是target_country然后从每行记录的custom_fields.target_country取出值。新增一个字段不需要改任何表结构只需要往form_schema里插入一条记录动态渲染就自动生效。我们做查询的时候还把最常用的几个自定义字段做了冗余字段快照比如把platform_type单独抽成一个普通列方便建索引。这样做属于工程妥协用很小的一致性成本换取查询效率我认为对于中小规模系统非常理智。4.3 时间线的底层设计事件表events是所有对象活动的垃圾桶。它包含actor_id操作人、tenant_id、resource_type客户 / 联系人 / 商机 / 订单、resource_id、event_type、payload、created_at。操作人更新商机金额会记录一条opportunity.field_updated事件payload 里存{field: amount, from: 10000, to: 15000}记录一通电话会记录一条activity.phone_call事件payload 存通话摘要和时长。这条表设计有一个很容易被忽略的细节resource_type和resource_id必须建立联合索引否则时间线的查询会在数据量变大后慢到不可接受。我们后来加了(tenant_id, resource_type, resource_id, created_at DESC)的复合索引列表页才从十秒优化到几百毫秒。这个教训让我意识到任何看起来统一又多态的表模型都必须先想好索引策略。5. 权限模型角色、数据范围与字段级权限的简化方案5.1 简单 RBAC 为什么不够用一般的权限系统第一反应就是 RBAC给用户分配角色给角色分配权限。但落地到客户管理场景光靠菜单权限远远不够。DeskcommCRM 必须回答三个问题张三能不能看到这个客户张三能不能编辑这个客户张三能不能看到这个客户的自定义字段客户毛利如果用 RBAC 硬做只能做到菜单级别的能进客户模块/不能进客户模块完全没有切到数据级。所以我的第一版权限模型直接跳过了传统 RBAC选择角色 数据范围规则 字段权限三层。5.2 数据范围规则全员 / 部门 / 仅本人 / 参与人数据范围我用了一个简单但务实的模型。每个用户有一个角色每个角色带一组数据范围规则全部可以看到所有租户内数据通常给管理员和老板本部门只能看自己所在部门的数据仅本人只能看自己创建或负责人是自己的数据参与人只要出现在一个商机的参与者列表里就能看到该商机关联的客户。这个规则的判定放在了 NestJS 的拦截器里不散落在各个 controller 中。拦截器会解析当前用户的角色和数据范围自动拼接到查询条件上。这样业务代码里写的都是我要查所有商机实际执行时会被追加where条件漏写法导致的越权基本被根治了。5.3 字段级权限不追求精细但要能兜底字段级权限我做了简化只控制可见性和可编辑性。在form_schema里每个字段除了类型和默认值还会带一个permissions对象。比如{ view: [role_admin, role_sales_manager], edit: [role_admin] }。前端拿到 schema 后会根据角色过滤可见字段后端在解析 payload 时也会根据角色决定是否接受该字段的更新。简化到这一步最大的价值是它让销售负责人能看到所有客户但普通销售只能看自己的客户这一类真实需求有了快捷配置入口。如果真要做到字段级动态脱敏、继承权限复杂度会指数上升对 40 人团队来说性价比太低。权限模型不是一个越精细越好的东西够用且可逃离才是关键。6. 核心功能实现动态表单、看板与自动化导入6.1 动态表单渲染器的关键思路动态表单的渲染器是 DeskcommCRM 使用体验的胜负手。我采用的方法是将 JSON Schema 和 Antd Form 绑定。form_schema里描述了一个字段数组每个字段包含name、label、component、props、options、rules。前端的动态表单组件遍历 schema根据component字段动态渲染输入框、下拉选择、日期选择、自定义人员选择器等。这个模式开发时比较顺利真正坑人的是字段联动。比如选择了客户类型为平台卖家需要联动展示店铺链接字段选择了独立站卖家要联动展示域名字段。我在form_schema里给字段加了一个visibleWhen声明式配置前端渲染时统一解析不把联动逻辑写死在各个业务组件里也让运营同学可以通过配置引擎实现简单的如果那么效果。6.2 销售看板的拖拽更新看板Kanban是销售最喜欢的视图按商机阶段分列卡片可以拖拽到下一个阶段。实现上我用一列stage字段来保存商机的阶段拖拽时前端将卡片从 A 列移到 B 列同时乐观更新本地 UI然后调用后端接口把stage改成 B。这里的核心问题是并发冲突与权限校验。用户 A 看到商机在谈判阶段拖到了赢单同时用户 B 已经从谈判拖到了丢失最后 A 的更新可能会覆盖 B 的数据。我的处理方案并不复杂前端在拖拽请求里附带当前阶段的version字段后端执行UPDATE ... SET stage $new, version version 1 WHERE id $id AND version $old影响行数为 0 就返回冲突错误前端再提示用户刷新重试。这个乐观锁模式在多数管理后台里足够用不必引入分布式锁。6.3 Excel 导入的三个典型陷阱客户管理系统的数据导入几乎避不开 Excel。我前后写了三版导入逻辑才把各种编码问题、表头匹配问题和大批量性能问题收拾利落。编码很多用户上传的 Excel 是 GBK 或带 BOM 的 UTF-8直接用xlsx库解析偶尔会乱码。我统一在上传时读取Buffer并转换编码再交给解析库。表头匹配不要假设 Excel 的列和系统字段一一对应更不能假设用户的表头名称完全一致。我在导入向导里提供了字段映射页面让用户手动把 Excel 列拖到系统字段上并且记住历史映射。大批量导入一次性插入一万条数据会把事务拉长还可能撑爆内存。我改成每 200 条一个批次每个批次在事务里完成动态字段和关联关系的写入失败写出错误日志不阻塞后续批次。这一模块上线后我们销售团队从原来每周花一下午整理表格变成了 5 分钟上传快速导入。工具类的价值就是如此看起来不起眼但能实打实解放人力。7. 部署上线Docker Compose、备份与线上问题清单7.1 单机部署的核心配置DeskcommCRM 的目标用户规模决定了我们不需要上 K8s一台 4C8G 的小机器就能跑起来。部署形态我用 Docker Compose 编排了五个服务postgres、api、web、nginx、redis。这里贴一段精简的 Compose 核心配置version: 3.9 services: postgres: image: postgres:15 restart: always environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U deskcomm] interval: 10s timeout: 5s retries: 5 api: build: ./server restart: always env_file: - .env.production depends_on: postgres: condition: service_healthy web: build: ./web restart: always nginx: image: nginx:1.25 restart: always volumes: - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - ${HTTP_PORT:-80}:80 depends_on: - api - web volumes: pgdata:启动命令没什么特别的核心是数据卷pgdata必须持久化否则容器一重启数据全丢。另外.env.production里的数据库密码和生产密钥我用的是环境变量注入而不是写死在代码里。很多自研系统最终倒在部署环节往往不是功能问题而是环境变量和密钥管理太随意。7.2 备份恢复实操只备份数据卷远远不够很多人以为只要 Docker 挂了数据卷就万事大吉真到恢复的时候会发现备份文件是坏的。DeskcommCRM 上线前我做了一次完整的恢复演练之后把备份写成了每天凌晨 3 点执行的脚本核心命令是这样的pg_dump -h postgres -U deskcomm deskcomm \ | gzip /backups/deskcomm_$(date %F).sql.gz恢复时只需要解压后执行psql -h postgres -U deskcomm deskcomm backup.sql。但要注意的是如果在事务里用了 RLS恢复时不能直接切换搜索路径。我们的做法是先恢复到一个临时的普通表空间确认数据完整后再通过一个迁移脚本把tenant_id和owner_id修正为当前租户状态。否则直接恢复后应用层会因为 RLS 找不到租户而提示无权限。备份这件事我最大的心得只有一句定期备份不如定期恢复演练。备份文件放在磁盘上没有任何价值只有确认能从零恢复到可运行状态才叫真正的备份。7.3 上线初期的三个线上问题复盘上线两周内我们记录到了三个比较典型的问题每一个都足够写一篇完整的排查报告。第一个是列表页 N1 查询。客户列表加载时需要展示最近一条跟进时间和负责人姓名第一版代码里在循环里查联系人和事件表一条列表请求发了上百条 SQL。用Prisma的日志插件看到慢查询后我改用left join加子查询把时间线的最新事件拉出来列表接口从 8 秒降到 150 毫秒。第二个是租户隔离失效的隐患。前文提到 RLS 需要每个事务设置app.current_tenant某次压测发现在错误处理分支里没有开启新事务导致连接复用了之前请求的租户上下文从而可能读到别的团队数据。修复很简单在 NestJS 拦截器里强制在每个请求的开始就设置租户上下文并且对没设置完成的事务直接抛异常而不是继续执行。第三个是前端动态表单的缓存问题。后台修改了字段定义前端因为本地缓存了旧的form_schema没有刷新导致页面渲染时找不到字段配置直接白屏。后来我给 schema 查询加了一个updated_at版本号前端在加载页面时每次比较版本不一致就强制重新拉取白屏问题才彻底消失。这类线上问题通常不是单点逻辑错误而是跨层、跨状态的拖泥带水。排查时最重要的是先看日志链路而不是猜测和打补丁。8. 现在怎么评价 DeskcommCRM使用反馈与后续规划8.1 团队从抵制系统到离不开系统上线最初两周团队里最大的声音是又多了一个要填的表格。当时我做了三件事改变了局面把历史表格里的数据一次性导入让销售不用重复录入把看板做成默认首页让每人打开系统第一眼就看到自己的待办商机砍掉一切非必要的必填字段能选填的绝不强制。一个季度之后系统里积累了接近四千条客户记录和一万两千条跟进事件销售开始主动在晨会上打开 DeskcommCRM 的看板展示项目进展。那个阶段我意识到客户管理系统能否落地有时根本不取决于功能多少而取决于与现有工作流之间的摩擦力有多大。每一次新增必填字段都要扪心自问这个字段带来的分析价值真的大于录入成本吗8.2 我学到的三句话第一不要为未来过度设计。最早规划的营销模块、报表模块、工单模块一年过去了真正用得上的只有数据导出和基础漏斗统计。过度扩展第一版范围只会让开发周期无限拉长上线遥遥无期。第二权限要早做但点到为止。租户隔离和数据显示范围一旦上线后再补需要改动大量查询和接口但字段级权限真的不需要一开始就做到复杂继承否则光设计权限模型就要一个月。第三企业软件的关键是数据可迁移。我在每个对象都设计了导出为 Excel的能力也会定期做完整数据库备份因为这个世界上没有哪个软件能永远用下去用户必须能够随时带着数据离开这才是对用户最大的尊重。8.3 后续准备做的三件事DeskcommCRM 下一步我会优先做三件事。一是把外部 API 完整开放出去让客户方或者合作方可以按 token 读取订单状态减少人工对接二是做一个轻量级的自动化工作流比如商机进入赢单阶段后自动创建订单提醒不用支持复杂的拖拽编排只要能配置几个触发规则就好三是离线版让销售在高铁上没网的环境也能查看客户资料和跟进记录有网络后再同步。我一直觉得一个内部工具最好的归宿不是被关在公司的服务器里而是能成为团队每天工作的自然延伸。DeskcommCRM 还有很长的路要走但很庆幸当初没有在要不要用商业软件这个问题上纠结太久。如果你也在考虑给自己的团队做一个内部的客户管理工具我的建议很简单从最小闭环开始先解决客户资料不统一和跟进记录不可追溯这两个痛点其它的等用完再迭代。

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

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

免费获取报价 →
↑