如果你问一个十几人的 B2B 销售团队最需要什么工具,十有八九会得到两个回答:一个是要把客户、报价和跟进记录收拢到一起的地方,另一个是别让自己忘了今天该给哪个客户打电话。去年我们把这两个需求做成了一套内部系统,名字叫 DeskcommCRM。当时只是想解决自己公司的销售管理问题,结果一年用下来,它反而成了业务后台里使用频率最高的系统。
DeskcommCRM 是一个面向小规模 B2B 销售团队的轻量级客户关系管理系统,核心解决三件事:客户信息统一沉淀、跟进记录连续可追溯、待办事项自动汇总到个人工作台。项目从原型到上线只用了一个多月,后续边用边改逐步稳定,中间踩了不少数据库和部署的坑,也总结出一些不写代码也能让团队用起来的地推式经验。这里把从业务调研、数据模型、技术选型到部署运维的完整过程整理出来,给正在考虑自研 CRM,或者准备在团队里推进销售数字化的朋友一个可以参照的样本。
1. 为什么一个外包服务团队,最后被逼着自研了DeskcommCRM
1.1 当时销售过程管理到底有多乱
团队大概十个人,业务以项目制外包为主,一个单子从需求沟通到签约,短则一周,长则三个月。最混乱的不是没有表格,而是表格太多。每个人手里都有一份自己维护的客户跟进表,有人用 Excel,有人用在线文档,还有人只靠聊天记录保存关键信息。月底统计商机金额的时候,销售经理得挨个问“你手头有几个单子在推进”,问出来的数字还经常对不上。
这种乱在刚开始还能忍,直到连续发生两次客户资源冲突:同一个客户被两个销售同时跟进,报价各报各的,客户直接质疑我们内部是不是没有沟通。这时候才意识到,问题不是销售不努力,而是缺少一个共同的业务事实源。客户信息、跟进历史、报价记录必须有一个系统承接,而不是依赖某个人的记忆或聊天记录检索。
1.2 市面上的CRM为什么没有留住我们
决定上系统之后,我们其实先把主流的 SaaS CRM 产品都试用了一遍。客观讲,功能确实全面,从线索导入、自动分配、电销一体到 BI 报表都有。但对十人左右的团队来说,问题也很明显:轻量版本的自定义能力通常被砍得很厉害,销售要用的核心字段经常被锁死;功能齐全的版本又带着一大堆我们用不上的模块,每个销售都要花时间去理解那些与日常无关的菜单,学习成本最后都变成团队效率的消耗。
另外一个很现实的因素是数据归属。客户联系方式、历史跟进记录、商务报价都属于核心业务资产,放在第三方平台上,每次想到数据导出和账号权限管理心里就没底。既然团队里已经有能写后端和前端的同事,不如自己动手做一个数据完全在自己手上的系统,DeskcommCRM 就是这么立项的。
1.3 DeskcommCRM 的定位和名字是怎么来的
项目代号是 Deskcomm,拆开看是 Desk + Communication,中文可以理解成“桌面沟通工作台”。Desk 强调团队每天打开电脑最先看到的就是它,系统第一屏不是统计报表,而是“今天要跟谁沟通”;Comm 则强调这个系统记录的核心是人与人之间的沟通,客户是活的,不是躺在表格里的静态字段。这个定位直接决定了后续产品结构:一个以“下一步行动”为引导的工作台,而不是一个只能查资料的客户数据库。
定位也收敛得很克制:面向 5 到 20 人的小规模 B2B 销售团队,不做销售过程自动化,也不做复杂营销漏斗,只把“客户、商机、跟进记录、待办提醒”这四件事管好。范围一旦收敛,设计和开发的方向立刻清晰了很多。
2. 数据模型设计:把公司、联系人和商机用清晰的模型串起来
2.1 先理清业务对象,再画表
开始写代码前,我们花了两个下午在白板上梳理业务对象。B2B 外包业务里,客户主体一定是“公司”,但真正打交道的是公司里的联系人。经常出现的情况是:先认识客户 A,没过多久 A 跳槽了,但公司还在,不能因为对接人走了就把客户历史删掉;反过来,一家大公司里可能有多个独立业务线,分别推进不同商机。因此客户模型必须有公司和联系人两个层级,每个商机挂在公司下,同时指定具体联系人。
对应到表结构,companies表存公司维度信息,contacts表存联系人,deals表存商机。所有表都保留owner_id,用来标识当前负责人,这也是后续做权限隔离的基础。实际跑了一年下来,这个模型比把公司和联系人混在一张表里的方案健壮很多。
CREATE TABLE companies ( id BIGSERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, industry VARCHAR(100), source VARCHAR(50), owner_id BIGINT REFERENCES users(id), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE contacts ( id BIGSERIAL PRIMARY KEY, company_id BIGINT NOT NULL REFERENCES companies(id), name VARCHAR(100) NOT NULL, title VARCHAR(100), phone VARCHAR(50), wechat VARCHAR(100), is_primary BOOLEAN DEFAULT false, created_at TIMESTAMPTZ DEFAULT now() ); CREATE TABLE deals ( id BIGSERIAL PRIMARY KEY, company_id BIGINT NOT NULL REFERENCES companies(id), contact_id BIGINT REFERENCES contacts(id), name VARCHAR(255) NOT NULL, amount NUMERIC(14, 2) DEFAULT 0, status VARCHAR(20) NOT NULL, owner_id BIGINT REFERENCES users(id), expected_close_date DATE, next_follow_up_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now() );2.2 跟进记录为什么单独建表
最初也有人提议把跟进记录做成deals表里的一个 JSON 字段,理由是“每次沟通内容直接拼到商机备注里,反正最后也是给人看的”。这个方案在第一周就被否掉了。原因一是备注越拼越长,数据库读取越来越慢,前端渲染也越来越卡;二是跟进记录的统计价值很大,比如按周看每个销售的沟通量、按商机看跟进了多少轮,这些都需要结构化数据来支撑。
所以我们单独建了activities表。每条跟进记录包含类型(电话、微信、现场拜访)、内容、操作人、创建时间。内容做成普通文本域,鼓励销售用一两句话记录关键信息,比如“客户对报价提出异议,希望折扣再降低 5%”。这些记录按时间倒序排列在商机详情页,整个谈单过程一目了然,完全替代了过去翻聊天记录的工作。
CREATE TABLE activities ( id BIGSERIAL PRIMARY KEY, deal_id BIGINT NOT NULL REFERENCES deals(id), user_id BIGINT NOT NULL REFERENCES users(id), type VARCHAR(20) NOT NULL, content TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_activities_deal_created ON activities(deal_id, created_at DESC);2.3 next_follow_up_at:整个工作台的心脏
系统上线第一个月,销售反馈最强烈的一个设计是deals表上的next_follow_up_at,也就是“下次跟进时间”。销售每天早上到公司不翻消息记录,打开首页工作台,系统自动把当天应跟进的商机排好,点击进入商机详情,里面就有上一次沟通的完整上下文。
为了防止这个字段失效,产品规则上做了一个强制动作:每次新增跟进记录时,必须顺手填写下次跟进时间,否则不允许保存。前期确实有人觉得麻烦,但两周后大家发现,这个“强制填写”恰恰是在帮自己不遗漏客户,反而成了系统口碑最好的功能。从数据模型看这就是一个普通时间戳,但在产品逻辑上,它是整个销售节奏的驱动力。
3. 技术栈与工程落地:FastAPI、React和PostgreSQL的组合逻辑
3.1 选型时为什么没有盲目追新
技术选型有一条原则:团队写起来最快、最容易维护的组合就是好组合。我们后端团队熟悉 Python,最终选了 FastAPI;前端选了 React + Ant Design;数据库用了 PostgreSQL;缓存和定时任务用 Redis + APScheduler。没有上微服务,也没有上 Kubernetes,因为这个系统的规模顶多服务几十个内部用户,单体应用加一台云服务器就足够了。
FastAPI 的优势比较直观:自带 OpenAPI 文档,接口调试不用额外开工具;基于 Pydantic 做参数校验,定义请求和响应模型时写起来很省心;异步特性对 CRM 这种 IO 密集场景也有一定帮助。更重要的是,Python 生态里做 Excel 导出、数据统计、写运维脚本都非常顺手,系统上后期加的一堆管理功能差不多都是短脚本实现的。
前端选择 React + Ant Design,主要看重的是表格、表单、抽屉这类后台组件很完整,对不擅长写 CSS 的后端开发者非常友好。数据库选 PostgreSQL,核心原因是 JSONB 支持和比较丰富的索引类型,后续如果要给客户资源增加自定义字段,不需要频繁改动表结构,可以直接在 JSONB 列里扩展。
3.2 后端API与权限校验的核心写法
CRM 系统的 API 大部分是对资源的增删改查,重点不在怎么写接口,而在权限控制和列表查询。我们使用 JWT 做身份认证,每个受保护接口都通过get_current_user依赖拿到当前用户。数据权限只做两级:普通销售只能看自己负责的数据,销售经理和管理员可以看全团队数据。
from fastapi import Depends, FastAPI, Header, Query from sqlalchemy import func, or_, select from sqlalchemy.orm import Session, selectinload app = FastAPI() def get_current_user(authorization: str = Header(...)): # 解析 JWT,返回当前用户对象 return decode_token(authorization) @app.get("/api/v1/deals") def list_deals( status: str | None = None, assignee: str = "me", keyword: str = "", page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), user = Depends(get_current_user), ): query = select(Deal).options( selectinload(Deal.company), selectinload(Deal.contact), ) if assignee == "me": query = query.where(Deal.owner_id == user.id) if status: query = query.where(Deal.status == status) if keyword: like = f"%{keyword}%" query = query.where( or_( Deal.name.ilike(like), Deal.company.has(Company.name.ilike(like)), ) ) total = db.scalar(select(func.count()).select_from(query.subquery())) items = db.scalars( query.order_by(Deal.next_follow_up_at.asc()) .offset((page - 1) * page_size) .limit(page_size) ).all() return {"total": total, "items": items}列表查询最容易踩的坑是 N+1。返回商机列表时,如果每条商机都单独查一次公司名和联系人名,接口性能会非常差。我们使用 SQLAlchemy 的selectinload一次性把关联对象加载出来,配合分页,上线后在几十万条记录下实测响应可以稳定在 300 毫秒内。
3.3 前端工作台:让销售一打开就知道今天该干什么
所有页面里,我们花心思最多的是首页工作台,而不是客户列表。工作台一共三个模块:今日需跟进商机、逾期未跟进任务、本周新增客户统计。顶部有全局搜索框,支持按公司名、联系人、商机名搜索。我们还额外加了一个拼音首字母检索字段,比如输入“xj”能搜到“新洁”。这个细节看起来不起眼,却让很多习惯用拼音打字的销售人员接受度提升了一大截。
工作台的查询逻辑很简单:找出当前用户负责、next_follow_up_at在今天之前的商机,按时间升序排列。逾期项用醒目的红色标识,单个卡片点击后直接跳到商机详情页,展示完整跟进历史。日常使用中,90% 的操作可以在一到两次点击内完成,这比提供复杂菜单重要得多。
4. 部署运维和真实踩坑:从Docker Compose上线到备份恢复
4.1 用一套Compose文件跑通所有服务
系统跑在一台 2 核 4G 的云主机上,用 Docker Compose 编排四个服务:前端 Nginx 容器、后端 API 容器、PostgreSQL、Redis。没有单独买负载均衡,最外层由 Nginx 监听 80/443,反向代理到前端容器,前端再通过 API 路径把请求转发到后端容器。
version: "3.8" services: db: image: postgres:15-alpine environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data ports: - "127.0.0.1:5432:5432" restart: always redis: image: redis:7-alpine restart: always api: build: ./backend environment: DATABASE_URL: postgresql://deskcomm:${DB_PASSWORD}@db:5432/deskcomm REDIS_URL: redis://redis:6379 depends_on: - db - redis restart: always web: build: ./frontend ports: - "8080:80" depends_on: - api restart: always volumes: pgdata:这里有个容易踩的坑:前端容器构建时把 API 地址编译进去,部署不同环境就会很痛苦。我们的解法是在 Nginx 配置里加一条location /api/ { proxy_pass http://api:8000; },前端所有请求都走相对路径/api,这样不管部署到哪,都不用重新构建前端镜像。
4.2 数据库优化:索引、连接池与慢查询
系统上线初期接口并不慢,但随着数据量累积,大约一个月后商机列表接口开始出现 1 秒以上的延迟。排查下来主要集中在两个地方:条件查询没有走索引,以及 ORM 默认懒加载导致的额外查询。
索引方面,除了主键,我们在deals表的owner_id + status + next_follow_up_at上加了联合索引,在activities表的deal_id + created_at上加了联合索引。连接池直接使用 SQLAlchemy 默认的pool_size=5和max_overflow=10,内部使用量不大,没有过度调优。值得一提的经验是:对客户名称表开启 PostgreSQL 的pg_trgm扩展,用 GIN 索引支撑模糊搜索,客户名搜索的响应速度有明显提升。
4.3 备份策略和时区问题要提前处理
备份用的是 crontab + pg_dump,每天凌晨 2 点全量压缩,保留最近 30 天。
#!/bin/bash BACKUP_DIR=/data/backup/deskcomm DATE=$(date +%F_%H-%M) mkdir -p "$BACKUP_DIR" docker compose exec -T db pg_dump -U deskcomm deskcomm | gzip > "$BACKUP_DIR/deskcomm_$DATE.sql.gz" find "$BACKUP_DIR" -name "*.sql.gz" -mtime +30 -delete实际发生过一次误删商机数据,从备份恢复后只丢了不到一天的新增记录,属于可接受范围。如果对数据完整性要求更高,可以考虑每天做多次增量备份,但对小团队一天一次基本够用。
时区是运营中很容易忽视的问题。我们踩过一个典型坑:销售晚上 8 点录跟进记录,系统存的是 UTC 时间,导致当天“今日新增跟进数”统计变成 0。最终方案是数据库统一用timestamptz存 UTC,所有展示都通过接口层根据用户时区转成北京时间,转换逻辑集中在一个工具函数里,不散落在页面各处。
5. 权限、报表和“团队真正愿意用”的落地细节
5.1 权限模型:用归属过滤代替复杂RBAC
小团队内部权限不需要做成动态 RBAC,我们只定义了三种角色:销售、Manager、Admin。销售只能看自己负责的数据;Manager 除了看全部数据,还能调整商机的负责人;Admin 负责账号管理和系统配置。
在代码里,这种权限用一个简单函数就能实现:查询时判断用户角色,如果不是 Admin 或 Manager,就自动在查询条件里追加owner_id == current_user.id。好处是逻辑直观,不容易出越权漏洞;坏处是未来如果要做跨团队协作,需要引入角色矩阵,但对当前场景来说完全够用。字段级权限也做了一层:普通销售的商机详情不显示成本价字段,这已经覆盖了我们所有的权限边界。
5.2 销售报表实现中的几个关键口径
报表模块是上线两周后才加的,一开始只需要一个视图:每月每个销售的新增商机数、赢单金额和赢单率。用 SQL 聚合写起来很快,但统计时要注意“金额归属时间”的定义。是按商机创建时间算,还是按赢单时间算?不同口径结果完全不同。我们统一按赢单时间计入当月业绩,创建时间只作为趋势分析参考。
SELECT u.name AS sales, COUNT(DISTINCT d.id) AS deal_count, SUM(CASE WHEN d.status = 'won' THEN d.amount ELSE 0 END) AS won_amount, ROUND( COUNT(CASE WHEN d.status = 'won' THEN 1 END)::numeric / NULLIF(COUNT(*), 0) * 100, 1 ) AS win_rate FROM deals d JOIN users u ON d.owner_id = u.id WHERE d.created_at >= date_trunc('month', now()) GROUP BY u.id, u.name ORDER BY won_amount DESC;这个 SQL 在几千条数据量下执行非常快。真正需要优化的是按月同比、环比这类跨整个历史范围的计算。因为内部报表只给两个管理者看,我们用 Redis 缓存每日结果,每天早上 8 点刷新一次,足够使用。
5.3 比功能更重要的:怎么让销售愿意打开这个系统
系统做得再完善,销售不打开就是白搭。我们总结出三条很土但很有效的经验。
第一,减少录入成本。客户和商机的关键字段尽量做成下拉选择或自动识别,能选的不让手输。联系人电话录入后自动格式化,公司名支持首字母检索。我们给自己定的目标是:销售录入一个新客户的时间控制在 30 秒以内。
第二,让系统替代“人盯人”。以前销售经理每周一早上都要在例会上挨个问“这周计划跟哪些客户”,系统上线后经理直接在后台看工作台的跟进计划,例会只讨论异常项,时间缩短了一半。
第三,及时反馈。系统发出待办提醒,销售点击处理后,界面要立刻变成“已完成”,这种即时反馈会鼓励人继续点击。若延迟超过一秒钟,用户就会觉得卡顿,慢慢开始排斥操作。这些细节不涉及高深技术,但正是它们让 DeskcommCRM 真正融入了日常业务。
最后再分享一点个人经验。DeskcommCRM 这个项目给我最大的收获,不是学会了 FastAPI 或 Docker Compose,而是意识到工具的价值永远取决于使用它的那些人。代码里最复杂的部分不是商机状态机,也不是权限过滤,而是让业务负责人愿意把团队的真实规则讲清楚。如果你也想自研一套 CRM,别急着写代码,先花一周时间坐到销售旁边,看看他们每天是怎么工作的。很多时候,一个简单的“下次跟进时间”提醒,比十个华丽的报表模板更有用。