
1. 为什么我们要自己做 DeskcommCRM而不是直接买一套现成的说真的最开始听到组里决定要自己搞一套 CRM 系统的时候我是有点抗拒的。市面上成熟的客户管理系统一抓一大把Salesforce、HubSpot、纷享销客、销售易哪个不是打磨了好几年我们一个小团队凭什么去重复造轮子。但真把需求捋完我发现这个结论其实没那么反直觉。DeskcommCRM 这个项目本质上不是要做一个通用的 CRM而是要做一套“长在我们办公场景里”的客户管理系统。Deskcomm 这个名字拆开看Desk 是桌面办公Comm 是通讯CRM 自然就是客户关系管理三个词拼在一起定位就非常清楚把桌面办公环境里的通话、即时消息、邮件往来和客户档案、跟进记录、商机管理全部打通让销售不用在多个系统之间来回切换。当时我们面临的实际问题很具体销售每天要打几十通电话加一堆微信好友邮件往来也频繁但客户信息散落在 Excel、手机通讯录、企业 IM 聊天记录和个人邮箱里。想回答“这个客户上次聊了什么”要翻半天想统计“这个月跟进了多少有效客户”更是无从下手。买现成的 CRM 能解决一部分问题但打通办公电话和 IM 这个需求几乎没有任何一家通用产品能开箱即用。加上按坐席收费的模式对初创团队来说也是一笔不小的开支。所以 DeskcommCRM 从一开始就不是跟成熟产品比功能多少而是盯准了一个更核心的问题怎样让客户信息自动沉淀、让沟通记录可追溯、让销售动作可量化。这套系统适合谁参考如果你所在的团队正在做客户管理又受困于数据散落和重复录入或者你想自己动手搭一套轻量级的 CRM把电话、IM、邮件整合到一个界面里那这篇文章里从架构设计到实操落地的完整过程应该能给你不少启发。我们做的不是什么高深的东西但每一处设计都是在真实业务里被逼出来的。2. 整体架构设计与核心数据模型2.1 技术选型为什么是前后端分离加 PostgreSQLDeskcommCRM 的技术栈选择上我们没有追求新奇选的全是社区生态成熟、团队上手成本低的东西。后端用 Python 的 FastAPI主要看重它三件事异步性能足够应付几百人的并发请求Pydantic 做参数校验和文档生成非常省事还有 WebSocket 原生支持到位。前端选了 Vue 3 加 Element Plus组件够用、文档中文资料多团队里前端同学上手快。数据库用了 PostgreSQL没有用 MySQL核心原因是我们要做全文检索和数据去重PostgreSQL 的 pg_trgm 扩展和强大的索引能力比 MySQL 顺手得多。这里我想多说一句选型的逻辑。很多人喜欢一门心思追新框架但在这种内部工具类的项目上稳定和熟悉远比炫技重要。我们组后端主攻 Python前端普遍写 Vue与其为了性能去引入 Go 或 React不如用自己最顺手的工具把业务逻辑做扎实。系统真正的复杂度根本不在框架而在数据模型和业务规则。2.2 客户、联系人、跟进记录的关系设计数据模型是整个系统的地基方案改起来成本最高。我们第一版设计时犯过一个典型错误把客户和联系人混在一张表里一个客户下面挂多个联系人时字段就变得非常别扭公司电话、个人电话、职位、部门全挤在一起查询和展示都一团糟。后来重构成了标准的三层模型customers 表存客户主体信息包括公司名称、行业、规模、来源渠道、所属销售contacts 表存具体联系人一个客户下面可以挂多个联系人每个联系人有自己的电话、邮箱、职位activities 表存所有跟进动作包括通话、消息、邮件、线下拜访、备注每条记录必关联到一个客户可以选关联具体联系人。deals 表才是真正的商机管理一个客户可以挂多个进行中的商机每个商机有金额、阶段、预计成交时间。这套模型的优点是客户是数据的唯一主轴线所有行为都挂在客户下面天然形成一个时间线。而且这种设计非常贴近销售的实际心智销售想了解一个客户只需要打开客户详情页所有历史往来按时间倒序排列一眼就能看明白。2.3 数据库层面的防重约束不能只靠前端提示数据质量问题里最头疼的就是重复。同一个客户被三个人录了三次同一个联系人的电话号码格式五花八门这些脏数据会让后续所有统计分析失真。我们的做法是在数据库层面做硬约束而不是只在前端弹个提示框。客户表上建了公司名称的规范化唯一索引不是直接对原始名称做唯一约束因为“某某科技有限公司”和“某某科技公司”在字面上完全不同但实际上是同一家。我们增加了一个 name_normalized 字段存储经过清洗、去掉公司后缀和空格的名字对这个字段做唯一索引。电话也一样通讯录里同一个号码可能有 86 前缀、有横线、有空格导入时统一转成 E.164 格式再对客户主电话和联系人的所有电话分别建唯一索引。约束带来一个问题导入数据时经常批量报错但这其实是好事。宁可导入时报错让人去合并也好过数据静默重复后面越堆越乱。想让一个 CRM 长期可用数据干净是第一位的这点放到多后面说都不过分。3. 核心模块与功能实现细节3.1 客户时间线所有交互历史串成一条线客户时间线是 DeskcommCRM 使用频率最高的模块也是我觉得整个系统最有价值的部分。它做的事情很简单把客户名下的所有通话记录、消息记录、邮件往来、跟进备注、商机状态变更按时间倒序排列成一个无限滚动的信息流。销售打开一个客户不用去各个子页面翻一段对话就能完整还原客户的跟进过程。时间线的技术实现关键在 activities 表的设计。我们给每条活动记录设了 type 字段call、message、email、note、deal_stage_change设了 content 字段存文本内容设了 metadata 字段存 JSON 格式的补充信息像通话时长、消息方向、邮件主题这些零散数据都塞进 metadata。查询时按客户 ID 过滤、按 created_at 倒序、分页拉取。前端渲染时根据 type 渲染不同的图标和卡片样式点击可以展开详情。这里有个经验想分享时间线看起来简单但性能坑不少。一开始我们没给 activities 表的 customer_id 和 created_at 建复合索引客户数据一多滑动时间线就明显卡顿。后来加了 (customer_id, created_at DESC) 的复合索引查询直接从几百毫秒降到十几毫秒。这类内部工具平时数据量不大但一旦用起来索引设计就决定体验上限。3.2 全局搜索让资料三秒内能找出来销售经常遇到一个场景客户打电话过来报了个公司名但接电话的人一时想不起是谁。如果搜索框也是“输入完整公司名才能搜到”那这个搜索就是摆设。我们期望的效果是输入“华科”能搜出“华科精密制造有限公司”输入手机号后几位能倒查出联系人输入一段聊天关键词能定位到具体跟进记录。PostgreSQL 的 pg_trgm 扩展帮了大忙。它通过 trigram 相似度做模糊匹配中文场景下效果出奇地好。我们给客户名称和联系人姓名字段建了 GIN 索引配合 pg_trgm 的 GIN 操作符用 ILIKE 加 %关键词% 这种查询也能走索引加速。搜索接口的聚合逻辑是先搜客户再搜联系人最后搜活动和商机把结果按类型分组返回。搜索耗时控制在 200 毫秒以内这个体验就很能打了。具体 SQL 上关键就靠下面这层索引和查询CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_customers_name_trgm ON customers USING GIN (name gin_trgm_ops); CREATE INDEX idx_contacts_name_trgm ON contacts USING GIN (name gin_trgm_ops);SELECT id, name, industry, owner_id FROM customers WHERE name ILIKE % || :keyword || % OR name_normalized ILIKE % || :keyword || % ORDER BY CASE WHEN name ILIKE :keyword || % THEN 0 WHEN name_normalized ILIKE :keyword || % THEN 1 ELSE 2 END, updated_at DESC LIMIT 20;ORDER BY 里的 CASE 表达式是搜索体验的细节关键完全前缀匹配的结果排最前其次模糊匹配最后才是相似度匹配这样能保证最相关的结果永远在最上面。纯靠数据库默认的相似度排序结果常常会因为一些奇怪的相似字而排错队。3.3 权限模型字段、记录、操作三层都要管CRM 里的数据大部分是敏感的客户资料、跟进记录、成交金额哪个泄露出去都是事故。DeskcommCRM 的权限模型一开始就设计了三层字段级权限控制哪些角色能看到哪些字段记录级权限控制能看哪些客户的记录操作级权限控制能做什么动作。记录级权限是核心。我们用的是 RBAC 加数据范围组合角色定义用户是普通销售、销售主管还是管理员数据范围定义了本人数据、本组数据、全公司数据。比如普通销售默认只能看到自己名下和曾经跟进过的客户销售主管能看到本组所有客户管理员有全公司数据权限。这个数据范围不是写死在代码里的 if 判断而是每个查询都拼接一个强制性的权限过滤条件从底层保证越权查询根本拿不到数据。字段级权限我们做得更细比如普通销售看客户时客户成本价、历史成交折扣这类敏感字段是直接隐藏的主管以上才能看到。操作级权限则决定了这个人能不能删除客户、能不能转移客户归属、能不能导出数据。三层权限叠加起来基本覆盖了所有常见的越权场景。权限做得好不好不看页面藏了几个按钮要看接口层有没有做同样的校验我们后面会在常见问题里细讲接口防越权。3.4 电话与消息自动关联从通讯工具到客户档案DeskcommCRM 最有特色的一块是把桌面通讯工具和客户档案打通。销售坐席在系统里点一下拨号通话结束后系统自动生成一条通话记录挂到客户时间线上。来电时根据电话号码自动匹配客户和联系人弹出来电浮窗显示客户的名称、上次跟进时间、最近一条备注。企业 IM 的消息记录也可以通过机器人回调主动推送到 CRM按联系人自动匹配归集。实现电话关联的核心是一个归一化函数所有号码入库时统一成 E.164 标准格式比如 86-138-1234-5678 会变成 8613812345678。来电匹配时先把来电号码做同样的归一化再拿归一化后的号码去 contacts 表和 customers 表的主电话字段里查。这个逻辑避免了“手机号带不带 0、带不带 86、中间有没有横线”导致匹配失败的问题。IM 的对接稍微复杂一点因为涉及回调签名验证和事件去重。我们的做法是收到回调后先验签再用消息 ID 做幂等判断同一个消息 ID 处理过一次就直接忽略避免重复写入时间线。这里踩过很深的一个坑回调推了两次时间线上出现了两条一模一样的消息。幂等判断是在数据库层用唯一索引实现的而不是在代码里判断代码判断在并发场景下照样会穿。4. 实操过程与核心环节落地4.1 环境准备与初始化项目开发环境基于 Docker Compose一条命令就能拉起整套后端和依赖服务。我们统一用 Docker 还有一个考量团队里 Windows 和 macOS 混用依赖装起来经常出各种奇怪问题容器化之后至少环境是一致的。# 项目根目录下执行 docker-compose up -dDocker Compose 文件里定义了三个服务postgres带 pg_trgm 扩展的数据库、redis缓存、apiFastAPI 应用。首次启动后需要执行数据库迁移和初始化脚本。我们用 Alembic 做迁移管理表结构的每次变更都记录在版本文件里开发环境和生产环境执行同一套迁移最大程度避免了“我这里能跑、你那里跑不起来”的问题。数据库初始化完成后会写入默认的角色数据和超级管理员账号。这一步也必须做成脚本而不是手工 SQL原因很简单整个团队的环境要保持一致手工执行 SQL 很容易漏步骤而且后续补数据非常麻烦。4.2 核心表结构与建表要点下面是我们实际使用的几张开表的核心结构为了不混淆我简化了字段但保留了最关键的部分。你可以直接拿去做骨架按自己业务加字段。-- 客户主表 CREATE TABLE customers ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, name_normalized VARCHAR(255) NOT NULL, industry VARCHAR(100), source VARCHAR(50) DEFAULT manual, owner_id BIGINT NOT NULL REFERENCES users(id), phone_e164 VARCHAR(50), email VARCHAR(255), status VARCHAR(20) DEFAULT active, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), version INT NOT NULL DEFAULT 1, CONSTRAINT uq_customers_name_normalized UNIQUE (name_normalized) ); -- 联系人表 CREATE TABLE contacts ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, name VARCHAR(100) NOT NULL, title VARCHAR(100), phone_e164 VARCHAR(50) NOT NULL, email VARCHAR(255), is_primary BOOLEAN DEFAULT FALSE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_contacts_phone UNIQUE (phone_e164) ); -- 跟进活动表 CREATE TABLE activities ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, contact_id BIGINT REFERENCES contacts(id) ON DELETE SET NULL, type VARCHAR(20) NOT NULL, content TEXT, metadata JSONB DEFAULT {}, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), FOREIGN KEY (customer_id) REFERENCES customers(id) ); CREATE INDEX idx_activities_customer_time ON activities (customer_id, created_at DESC); -- 商机表 CREATE TABLE deals ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL REFERENCES customers(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, amount DECIMAL(12, 2) NOT NULL DEFAULT 0, stage VARCHAR(50) NOT NULL DEFAULT lead, expected_close_date DATE, owner_id BIGINT NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );几个建表细节想单独拎出来说。第一所有时间字段统一用 TIMESTAMPTZ存的是 UTC 时间展示层再根据用户时区转成本地时间这样不管团队在哪个城市数据都是同一套时间基准不会出现跨时区协作时看到的时间不一致。第二customers 表加了 version 字段这是做乐观锁用的后面讲并发更新时会细说。第三contacts 表的 phone_e164 建了唯一索引这保证了同一个手机号不能被录到两个联系人下面避免同一个客户被不同销售抢着维护时产生混乱。第四外键的 ON DELETE 规则必须按业务语义定清楚客户删除时联系人跟着删但活动记录不能删因为可能跨客户引用历史记录白纸黑字的数据不能丢。4.3 后端接口示例创建客户和添加跟进记录接口是系统的门面参数校验和错误返回必须做得严谨。我们用 FastAPI 的 Pydantic 模型定义请求和响应格式自动生成 OpenAPI 文档前端直接照着文档对接省了很多沟通成本。下面是创建客户和添加跟进记录两个核心接口的精简代码。from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session router APIRouter(prefix/api) class CustomerCreate(BaseModel): name: str industry: str | None None phone: str | None None email: str | None None source: str manual router.post(/customers) def create_customer(data: CustomerCreate, db: Session Depends(get_db)): normalized normalize_company_name(data.name) existing db.query(Customer).filter_by(name_normalizednormalized).first() if existing: raise HTTPException(status_code409, detail客户名称已存在疑似重复) phone_e164 None if data.phone: phone_e164 normalize_phone(data.phone) customer Customer( namedata.name, name_normalizednormalized, industrydata.industry, phone_e164phone_e164, emaildata.email, sourcedata.source, owner_idcurrent_user.id, version1, ) db.add(customer) db.commit() db.refresh(customer) return customer.to_dict()class ActivityCreate(BaseModel): customer_id: int contact_id: int | None None type: str content: str | None None metadata: dict {} router.post(/activities) def create_activity(data: ActivityCreate, db: Session Depends(get_db)): customer db.get(Customer, data.customer_id) if not customer: raise HTTPException(status_code404, detail客户不存在) # 权限校验只能给有权限的客户添加活动 if not can_access_customer(current_user, customer): raise HTTPException(status_code403, detail无权操作该客户) activity Activity( customer_iddata.customer_id, contact_iddata.contact_id, typedata.type, contentdata.content, metadatadata.metadata, ) db.add(activity) db.commit() db.refresh(activity) return activity.to_dict()创建客户时对名称做了规范化后再查重这个逻辑是防重的第一道门槛但是要注意它依赖 normalize_company_name 函数写得够不够健壮。我们的版本会去掉首尾空格、全角转半角、去掉“有限公司”“股份有限公司”“有限责任公司”等常见后缀还会处理括号和横线。中文公司名的规范化没有银弹得结合自己客户的命名习惯不断调。电话号码的归一化也是同类问题中国手机号有 11 位、可能有 86 前缀、可能有 0 开头座机需要单独写一批规则慢慢磨。权限校验有个容易被忽略的点必须在创建活动的接口里也做一次而不只是前端隐藏按钮。因为恶意用户完全可以直接调 API 绕过前端界面这一点在常见问题部分我还会展开讲。4.4 前端核心交互快速录入与全局搜索框前端的体验目标定得很直接让销售在 10 秒内完成一次客户录入在 3 秒内找到任何一条历史信息。快速录入组件做了一个模态框聚焦客户名称后直接 Enter 就创建不用点保存。如果输的名字匹配到已有客户会先弹出来提醒“你是不是要找这个客户”防止重复创建。全局搜索框是前端所有操作的入口放在顶部导航的固定位置任何页面下都能随时唤起键盘快捷键是 CtrlK。搜索请求做了 300ms 防抖避免每敲一个字符都打一次接口。下拉结果里按客户、联系人、活动、商机分组展示每组最多显示 5 条点击后跳到对应详情页。这个交互看起来简单但实际开发中防抖的延迟值、结果分组策略、快捷键冲突处理每一个点都需要调优。客户详情页的时间线渲染是最重的前端部分。每个时间线条目根据类型渲染成卡片卡片上有操作人、时间、内容摘要和展开按钮。列表用虚拟滚动一开始我们是直接渲染全部 DOM客户活动一多浏览器直接卡成幻灯片。换成虚拟滚动后不管活动有几万条页面都稳定在 60 帧。这个技术细节在你自己的项目里很可能用得上永远不要一次性渲染大量列表尤其是无限滚动的历史记录。5. 踩坑实录与问题排查速查5.1 CSV 导入乱码和时区错位第一次从 Excel 导客户数据进来的时候满屏乱码。原因很简单Windows 上 Excel 导出的 CSV 是 GBK 编码而我们数据库默认按 UTF-8 导入。处理方式有两层上传时自动检测编码检测到非 UTF-8 就用 iconv 转码另外给客户提供 CSV 模板下载模板里统一带 UTF-8 BOM这样用 Excel 打开也不会乱。转码逻辑放在导入服务的最前面所有导入文件先过一遍编码检测再进解析器。时区错位是另一个隐蔽的坑。Excel 里的日期是“2024-06-15 14:30:00”看起来没有时区信息但解析进 TIMESTAMPTZ 字段时Python 默认按服务器时区解释而服务器跑的又是 UTC结果前端展示时所有时间都差了 8 个小时。解决办法是导入表单里加一个时区选择用户选了北京时间解析时就强制指定 Asia/Shanghai 再转 UTC 存入。这个坑的价值在于提醒你任何涉及用户输入时间的系统都要在入口处明确时区语义不要依赖运行环境的默认时区。5.2 并发更新导致数据丢失乐观锁是关键两个销售同时打开同一个客户详情页一个改了行业分类一个改了客户状态后提交的会把先提交的覆盖掉。这种问题在早期版本真实发生过而且特别难排查因为不是每次都会复现完全看操作顺序。解决方案是乐观锁更新 customers 表时SQL 里带上 version 条件。UPDATE customers SET industry :industry, version version 1 WHERE id :id AND version :version RETURNING version;如果更新的影响行数是 0说明 version 不匹配即这条数据在读取后被别人改过了此时后端返回 409 冲突提示前端“数据已被其他人修改请刷新后重试”。这个方案不用加行锁性能开销极小又能保证并发场景下的最终一致性。任何需要多人协作编辑的数据实体都该考虑加 version 字段它花不了多少成本但能避免大量脏数据覆盖。5.3 搜索变慢索引失效的排查过程系统上线跑了两三个月客户数据到几万条后搜索突然开始变慢有时候要等两三秒。查了执行计划发现有些模糊搜索根本没走 GIN 索引而是走了全表扫描。原因出在 pg_trgm 对查询词长度有要求默认至少 3 个字符才有效的 trigram。我们搜索关键词“华为”只有两个字生成的 trigram 太少优化器直接决定不走索引。解决办法是调整 pg_trgm 的 pg_trgm_word_similarity_threshold 参数同时给搜索接口做了个特殊处理关键词少于 3 个字符时改用按前缀匹配普通 B-tree 索引加 LIMIT 的查询策略避免全表扫。这个优化做完之后即使是单字关键词响应也能稳定在 100 毫秒以内。这个坑给我们的教训是索引不是建了就完事要结合真实查询场景不断验证执行计划尤其要注意中文字符的特殊性。5.4 权限绕过接口层的防越权清单权限问题我们吃过一次大亏。有一版前端改造后有些查询接口临时去掉了鉴权代码结果任何一个登录用户都能通过直接构造 API 请求看到全公司所有客户的资料。虽然没造成实际损失但这件事让我们把接口安全彻底梳理了一遍并形成了一份必须逐条检查的清单检查项说明状态列表接口是否按数据范围过滤查询客户列表时必须在 SQL 层拼接 owner 或部门条件而不是查询后在内存里过滤必须详情接口是否越权可查通过客户 ID 直接查详情时必须先校验当前用户是否有该客户访问权限必须写操作是否越权可改修改客户、添加跟进记录时同样要校验操作权限防止越权修改他人数据必须批量导出是否越权导出接口要对导出数据条数和范围做权限校验不能全量拉取必须校验逻辑是否落在后端前端隐藏按钮、禁用输入框只是体验不做安全边界必须这条清单纯粹是血泪教训换来的现在每次发版前我们都会拿这条清单过一遍所有接口谁负责的模块谁签字确认。安全这个东西做一次是不够的得变成流程的一部分才能守住。5.5 其他常见问题速查现象原因解法导入数据报唯一约束冲突客户已存在或电话号码重复导入日志里标出冲突行提供“合并到已有客户”或“跳过”选项客户详情页时间线打开很慢activities 表缺少合适的复合索引确保有 (customer_id, created_at DESC) 复合索引并检查执行计划搜索无结果但数据库里有搜索关键词太短或 pg_trgm 阈值影响短词走前缀匹配长词走 trigram 匹配两条路径都要测回调消息重复写入消息 ID 未做幂等约束在消息表对 source_id 建唯一索引重复入库直接报错并忽略删除客户后活动记录丢失外键 ON DELETE CASCADE 设置不当客户删除改为软删除活动记录永久保留数据不能物理删6. 我们踩过的“非技术坑”以及 DeskcommCRM 的下一步项目做到后面最深的感受是技术问题都有解真正的复杂度在业务规则和数据治理上。DeskcommCRM 能在这个团队里活下来不是因为我们代码写得多漂亮而是我们一直在回答一个核心问题销售打开这个系统时能不能比用 Excel 更快地找到他想要的信息。如果一个功能不能让销售节省时间那它再炫酷也应该被砍掉。我们砍掉过仪表盘上十几个图表因为没人看保留了最简单的“本周新增客户、本周跟进次数”两个数字因为销售主管每天早上真的会看。如果你也准备做类似的项目我的建议是从最小闭环开始先做客户档案加跟进时间线这两个功能打透了系统的骨架就立住了。商机管理、报表分析、通讯集成这些都等核心链路跑顺了再加。别一开始就把功能规划得又大又全那是给自己挖坑。最后分享一个我们正在做的小改进把自动摘要和跟进提醒做成更智能的规则引擎。比如客户超过 7 天没有跟进动作系统自动提醒销售同时把最近的往来记录推送到时间线顶部。这些功能都不复杂但每一个都是销售真正需要的。DeskcommCRM 到现在也不是一个多完美的系统但它是真正长在我们业务里、每天有人在使用和提需求的东西这一点比任何功能列表都重要。